{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "9b182369-a759-4928-9e15-11eff664cf28",
   "metadata": {},
   "source": [
    "# 유학생을 위한 중고거래 다국어 플랫폼\n",
    "\n",
    "## 1. 크롤링 환경 설정\n",
    "\n",
    "본 프로젝트는 부산외국어대학교 유학생들이 한국 중고거래 서비스를 이용할 때 겪는 언어 장벽과 가격 적정성 판단의 어려움을 해결하기 위해 기획되었다.\n",
    "\n",
    "먼저 중고거래 매물 데이터를 수집하기 위해 Selenium 기반 크롤링 환경을 설정한다.  \n",
    "당근마켓 페이지는 동적으로 로딩되는 요소가 많기 때문에 일반적인 `requests` 방식이 아니라 실제 브라우저를 제어하는 Selenium을 사용하였다.\n",
    "\n",
    "이번 단계에서는 `undetected_chromedriver`를 이용하여 Chrome 브라우저를 실행하고, 이후 지역 검색과 상품 목록 수집을 자동화할 준비를 한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "a3213cad-2160-41f1-9e13-35a5eb624f7c",
   "metadata": {},
   "outputs": [],
   "source": [
    "import undetected_chromedriver as uc\n",
    "from selenium.webdriver.common.by import By\n",
    "from selenium.webdriver.common.keys import Keys\n",
    "import pandas as pd\n",
    "import time\n",
    "\n",
    "# PC의 크롬 버전(150)에 맞춰 드라이버를 실행하도록 지정\n",
    "driver = uc.Chrome(version_main=150)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c6d19994-c761-4aca-a45d-e61d37c489e3",
   "metadata": {},
   "source": [
    "## 2. 당근마켓 중고거래 페이지 접속\n",
    "\n",
    "크롤링을 시작하기 위해 당근마켓 중고거래 검색 페이지에 접속한다.\n",
    "\n",
    "처음 접속하는 URL은 특정 지역이 포함된 중고거래 검색 페이지이며, 이후 지역 필터를 활용해 부산광역시 내 여러 동네의 중고거래 페이지 URL을 수집한다.\n",
    "\n",
    "페이지가 완전히 로딩될 수 있도록 `time.sleep()`을 사용하여 일정 시간 대기하였다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "id": "d7dfe15d-7136-4da4-bd00-8f5a740f0682",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 작업 중이신 당근 페이지 주소를 넣어주세요\n",
    "driver.get(\"https://daangn.com/kr/buy-sell/s/?in=%EC%A0%95%EA%B4%80%EC%9D%8D-4121\") \n",
    "time.sleep(3) # 접속 로딩 대기"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8393b231-9543-4bac-a108-5ef180f8703e",
   "metadata": {},
   "source": [
    "## 3. 지역 필터 버튼 클릭\n",
    "\n",
    "부산 지역의 동네별 중고거래 데이터를 수집하기 위해 먼저 지역 필터 버튼을 클릭한다.\n",
    "\n",
    "당근마켓 검색 페이지에서는 지역 조건을 직접 설정해야 원하는 지역의 중고거래 매물을 확인할 수 있다.  \n",
    "따라서 페이지 내 버튼 요소를 찾고, 그중 지역 필터에 해당하는 버튼을 선택하여 클릭하였다.\n",
    "\n",
    "일반 클릭이 잘 작동하지 않을 수 있어 JavaScript 클릭 방식을 사용하였다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "id": "b233b0e9-b53c-4880-b79a-c7dd4d96386f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# [1단계] 2번 버튼 직접 클릭\n",
    "btns = driver.find_elements(By.CSS_SELECTOR, \"button.seed-action-button\")\n",
    "target_btn = btns[2]\n",
    "\n",
    "# 버튼 중앙으로 스크롤 이동\n",
    "driver.execute_script(\"arguments[0].scrollIntoView({block: 'center'});\", target_btn)\n",
    "time.sleep(0.5)\n",
    "\n",
    "# 자바스크립트로 클릭 이벤트 실행\n",
    "driver.execute_script(\"arguments[0].click();\", target_btn)\n",
    "time.sleep(1)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11c64ef1-2d24-4a58-98b1-a58daf737d41",
   "metadata": {},
   "source": [
    "## 4. 지역 검색창에 부산광역시 입력\n",
    "\n",
    "지역 필터 창이 열린 뒤, 검색창에 `부산광역시`를 입력한다.\n",
    "\n",
    "이 단계의 목적은 부산광역시에 포함된 여러 동네 목록을 불러오는 것이다.  \n",
    "이후 각 동네를 하나씩 클릭하여 동네별 중고거래 페이지 URL을 수집한다.\n",
    "\n",
    "검색어 입력 후에는 검색 결과가 렌더링될 시간을 확보하기 위해 일정 시간 대기하였다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "id": "4b785f59-278e-4458-b672-34ce53ccaf08",
   "metadata": {},
   "outputs": [],
   "source": [
    "# [2단계] 검색창 탐색 및 \"부산광역시\" 입력\n",
    "search_input = driver.find_element(By.CSS_SELECTOR, \"input[aria-label='지역 검색']\")\n",
    "\n",
    "# 검색창 포커스 및 기존 내용 초기화\n",
    "search_input.click()\n",
    "search_input.send_keys(Keys.CONTROL + \"a\")\n",
    "search_input.send_keys(Keys.BACKSPACE)\n",
    "\n",
    "# \"부산광역시\" 입력\n",
    "search_input.send_keys(\"부산광역시\")\n",
    "\n",
    "# 드롭박스 검색 목록 렌더링 대기\n",
    "time.sleep(1.5)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7b39862d-32d2-42f1-81cf-87ae40ca6d34",
   "metadata": {},
   "source": [
    "## 5. 부산광역시 동네별 URL 수집\n",
    "\n",
    "이 단계에서는 부산광역시 검색 결과에 나타난 동네 목록을 하나씩 선택하고, 각 동네에 해당하는 중고거래 페이지 URL을 수집한다.\n",
    "\n",
    "수집 과정은 다음 순서로 진행된다.\n",
    "\n",
    "1. 지역 필터 버튼을 다시 클릭한다.\n",
    "2. 검색창에 `부산광역시`를 입력한다.\n",
    "3. 검색 결과에 표시된 동네 버튼 중 i번째 항목을 선택한다.\n",
    "4. 선택된 동네의 페이지 URL을 가져온다.\n",
    "5. 지역명과 URL을 리스트에 저장한다.\n",
    "\n",
    "이렇게 수집한 동네별 URL은 이후 각 지역의 중고거래 매물을 반복적으로 수집하는 데 사용된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "id": "a693e881-0e3b-41b3-91b4-53a28fb4467a",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "총 100개의 동네 항목을 찾았습니다. 수집을 시작합니다...\n",
      "\n",
      "[1/100] 부산광역시 기장군 정관읍 -> https://daangn.com/kr/buy-sell/s/?in=%EC%A0%95%EA%B4%80%EC%9D%8D-4121\n",
      "[2/100] 부산광역시 기장군 기장읍 -> https://daangn.com/kr/buy-sell/s/?in=%EA%B8%B0%EC%9E%A5%EC%9D%8D-670\n",
      "[3/100] 부산광역시 강서구 명지동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%AA%85%EC%A7%80%EB%8F%99-5873\n",
      "[4/100] 부산광역시 기장군 일광읍 -> https://daangn.com/kr/buy-sell/s/?in=%EC%9D%BC%EA%B4%91%EC%9D%8D-672\n",
      "[5/100] 부산광역시 강서구 명지2동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%AA%85%EC%A7%802%EB%8F%99-4211\n",
      "[6/100] 부산광역시 강서구 명지1동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%AA%85%EC%A7%801%EB%8F%99-628\n",
      "[7/100] 부산광역시 영도구 동삼동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EC%82%BC%EB%8F%99-5968\n",
      "[8/100] 부산광역시 강서구 신호동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%8B%A0%ED%98%B8%EB%8F%99-5881\n",
      "[9/100] 부산광역시 서구 암남동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%95%94%EB%82%A8%EB%8F%99-475\n",
      "[10/100] 부산광역시 강서구 지사동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%A7%80%EC%82%AC%EB%8F%99-5884\n",
      "[11/100] 부산광역시 강서구 강동동 -> https://daangn.com/kr/buy-sell/s/?in=%EA%B0%95%EB%8F%99%EB%8F%99-627\n",
      "[12/100] 부산광역시 강서구 대저2동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%EC%A0%802%EB%8F%99-626\n",
      "[13/100] 부산광역시 기장군 장안읍 -> https://daangn.com/kr/buy-sell/s/?in=%EC%9E%A5%EC%95%88%EC%9D%8D-671\n",
      "[14/100] 부산광역시 영도구 동삼제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EC%82%BC%EC%A0%9C1%EB%8F%99-499\n",
      "[15/100] 부산광역시 영도구 청학동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B2%AD%ED%95%99%EB%8F%99-5981\n",
      "[16/100] 부산광역시 강서구 송정동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%86%A1%EC%A0%95%EB%8F%99-5879\n",
      "[17/100] 부산광역시 강서구 대저1동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%EC%A0%801%EB%8F%99-625\n",
      "[18/100] 부산광역시 강서구 화전동 -> https://daangn.com/kr/buy-sell/s/?in=%ED%99%94%EC%A0%84%EB%8F%99-5886\n",
      "[19/100] 부산광역시 동구 초량제3동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B4%88%EB%9F%89%EC%A0%9C3%EB%8F%99-479\n",
      "[20/100] 부산광역시 서구 서대신동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%9C%EB%8C%80%EC%8B%A0%EB%8F%992%EA%B0%80-5944\n",
      "[21/100] 부산광역시 서구 서대신제3동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%9C%EB%8C%80%EC%8B%A0%EC%A0%9C3%EB%8F%99-467\n",
      "[22/100] 부산광역시 서구 서대신동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%9C%EB%8C%80%EC%8B%A0%EB%8F%991%EA%B0%80-5943\n",
      "[23/100] 부산광역시 영도구 청학제2동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B2%AD%ED%95%99%EC%A0%9C2%EB%8F%99-498\n",
      "[24/100] 부산광역시 서구 서대신동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%9C%EB%8C%80%EC%8B%A0%EB%8F%993%EA%B0%80-5945\n",
      "[25/100] 부산광역시 기장군 철마면 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B2%A0%EB%A7%88%EB%A9%B4-674\n",
      "[26/100] 부산광역시 영도구 동삼제3동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EC%82%BC%EC%A0%9C3%EB%8F%99-501\n",
      "[27/100] 부산광역시 서구 남부민동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%EB%B6%80%EB%AF%BC%EB%8F%99-5934\n",
      "[28/100] 부산광역시 영도구 남항동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%ED%95%AD%EB%8F%99-491\n",
      "[29/100] 부산광역시 서구 동대신제3동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EB%8C%80%EC%8B%A0%EC%A0%9C3%EB%8F%99-465\n",
      "[30/100] 부산광역시 서구 부민동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B6%80%EB%AF%BC%EB%8F%99-469\n",
      "[31/100] 부산광역시 서구 동대신동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EB%8C%80%EC%8B%A0%EB%8F%993%EA%B0%80-5937\n",
      "[32/100] 부산광역시 서구 동대신동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EB%8C%80%EC%8B%A0%EB%8F%992%EA%B0%80-5936\n",
      "[33/100] 부산광역시 서구 서대신제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%9C%EB%8C%80%EC%8B%A0%EC%A0%9C1%EB%8F%99-466\n",
      "[34/100] 부산광역시 서구 동대신동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EB%8C%80%EC%8B%A0%EB%8F%991%EA%B0%80-5935\n",
      "[35/100] 부산광역시 영도구 청학제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B2%AD%ED%95%99%EC%A0%9C1%EB%8F%99-497\n",
      "[36/100] 부산광역시 서구 아미동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%95%84%EB%AF%B8%EB%8F%99-470\n",
      "[37/100] 부산광역시 영도구 봉래동4가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EB%8F%994%EA%B0%80-5972\n",
      "[38/100] 부산광역시 서구 충무동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B6%A9%EB%AC%B4%EB%8F%99-472\n",
      "[39/100] 부산광역시 서구 서대신제4동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%9C%EB%8C%80%EC%8B%A0%EC%A0%9C4%EB%8F%99-468\n",
      "[40/100] 부산광역시 영도구 동삼제2동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EC%82%BC%EC%A0%9C2%EB%8F%99-500\n",
      "[41/100] 부산광역시 강서구 녹산동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%85%B9%EC%82%B0%EB%8F%99-630\n",
      "[42/100] 부산광역시 영도구 영선동4가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%98%81%EC%84%A0%EB%8F%994%EA%B0%80-5980\n",
      "[43/100] 부산광역시 강서구 미음동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%AF%B8%EC%9D%8C%EB%8F%99-5874\n",
      "[44/100] 부산광역시 동구 초량제6동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B4%88%EB%9F%89%EC%A0%9C6%EB%8F%99-480\n",
      "[45/100] 부산광역시 서구 토성동1가 -> https://daangn.com/kr/buy-sell/s/?in=%ED%86%A0%EC%84%B1%EB%8F%991%EA%B0%80-5951\n",
      "[46/100] 부산광역시 서구 남부민제2동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%EB%B6%80%EB%AF%BC%EC%A0%9C2%EB%8F%99-474\n",
      "[47/100] 부산광역시 서구 남부민제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%EB%B6%80%EB%AF%BC%EC%A0%9C1%EB%8F%99-473\n",
      "[48/100] 부산광역시 영도구 신선동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%8B%A0%EC%84%A0%EB%8F%99-494\n",
      "[49/100] 부산광역시 서구 동대신제2동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EB%8C%80%EC%8B%A0%EC%A0%9C2%EB%8F%99-464\n",
      "[50/100] 부산광역시 서구 초장동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B4%88%EC%9E%A5%EB%8F%99-471\n",
      "[51/100] 부산광역시 영도구 봉래동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EB%8F%992%EA%B0%80-5970\n",
      "[52/100] 부산광역시 영도구 영선동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%98%81%EC%84%A0%EB%8F%992%EA%B0%80-5978\n",
      "[53/100] 부산광역시 서구 아미동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%95%84%EB%AF%B8%EB%8F%992%EA%B0%80-5947\n",
      "[54/100] 부산광역시 강서구 범방동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B2%94%EB%B0%A9%EB%8F%99-5875\n",
      "[55/100] 부산광역시 영도구 봉래동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EB%8F%991%EA%B0%80-5969\n",
      "[56/100] 부산광역시 서구 동대신제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EB%8C%80%EC%8B%A0%EC%A0%9C1%EB%8F%99-463\n",
      "[57/100] 부산광역시 서구 부용동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B6%80%EC%9A%A9%EB%8F%991%EA%B0%80-5941\n",
      "[58/100] 부산광역시 서구 부민동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B6%80%EB%AF%BC%EB%8F%993%EA%B0%80-5940\n",
      "[59/100] 부산광역시 영도구 봉래제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EC%A0%9C1%EB%8F%99-495\n",
      "[60/100] 부산광역시 영도구 남항동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%ED%95%AD%EB%8F%991%EA%B0%80-5961\n",
      "[61/100] 부산광역시 서구 부용동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B6%80%EC%9A%A9%EB%8F%992%EA%B0%80-5942\n",
      "[62/100] 부산광역시 영도구 대교동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%EA%B5%90%EB%8F%992%EA%B0%80-5965\n",
      "[63/100] 부산광역시 영도구 신선동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%8B%A0%EC%84%A0%EB%8F%992%EA%B0%80-5975\n",
      "[64/100] 부산광역시 영도구 봉래동5가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EB%8F%995%EA%B0%80-5973\n",
      "[65/100] 부산광역시 서구 토성동5가 -> https://daangn.com/kr/buy-sell/s/?in=%ED%86%A0%EC%84%B1%EB%8F%995%EA%B0%80-5955\n",
      "[66/100] 부산광역시 영도구 영선동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%98%81%EC%84%A0%EB%8F%993%EA%B0%80-5979\n",
      "[67/100] 부산광역시 강서구 가덕도동 -> https://daangn.com/kr/buy-sell/s/?in=%EA%B0%80%EB%8D%95%EB%8F%84%EB%8F%99-631\n",
      "[68/100] 부산광역시 서구 부민동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B6%80%EB%AF%BC%EB%8F%991%EA%B0%80-5938\n",
      "[69/100] 부산광역시 강서구 생곡동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%83%9D%EA%B3%A1%EB%8F%99-5877\n",
      "[70/100] 부산광역시 영도구 영선제2동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%98%81%EC%84%A0%EC%A0%9C2%EB%8F%99-493\n",
      "[71/100] 부산광역시 서구 충무동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B6%A9%EB%AC%B4%EB%8F%991%EA%B0%80-5948\n",
      "[72/100] 부산광역시 영도구 대교동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%EA%B5%90%EB%8F%991%EA%B0%80-5964\n",
      "[73/100] 부산광역시 영도구 봉래동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EB%8F%993%EA%B0%80-5971\n",
      "[74/100] 부산광역시 서구 부민동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B6%80%EB%AF%BC%EB%8F%992%EA%B0%80-5939\n",
      "[75/100] 부산광역시 영도구 영선동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%98%81%EC%84%A0%EB%8F%991%EA%B0%80-5977\n",
      "[76/100] 부산광역시 서구 충무동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B6%A9%EB%AC%B4%EB%8F%993%EA%B0%80-5950\n",
      "[77/100] 부산광역시 영도구 대평동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%ED%8F%89%EB%8F%991%EA%B0%80-5966\n",
      "[78/100] 부산광역시 영도구 봉래제2동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%9E%98%EC%A0%9C2%EB%8F%99-496\n",
      "[79/100] 부산광역시 강서구 구랑동 -> https://daangn.com/kr/buy-sell/s/?in=%EA%B5%AC%EB%9E%91%EB%8F%99-5869\n",
      "[80/100] 부산광역시 서구 토성동2가 -> https://daangn.com/kr/buy-sell/s/?in=%ED%86%A0%EC%84%B1%EB%8F%992%EA%B0%80-5952\n",
      "[81/100] 부산광역시 강서구 가락동 -> https://daangn.com/kr/buy-sell/s/?in=%EA%B0%80%EB%9D%BD%EB%8F%99-629\n",
      "[82/100] 부산광역시 영도구 남항동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%ED%95%AD%EB%8F%993%EA%B0%80-5963\n",
      "[83/100] 부산광역시 서구 토성동3가 -> https://daangn.com/kr/buy-sell/s/?in=%ED%86%A0%EC%84%B1%EB%8F%993%EA%B0%80-5953\n",
      "[84/100] 부산광역시 영도구 신선동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%8B%A0%EC%84%A0%EB%8F%991%EA%B0%80-5974\n",
      "[85/100] 부산광역시 영도구 영선제1동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%98%81%EC%84%A0%EC%A0%9C1%EB%8F%99-492\n",
      "[86/100] 부산광역시 강서구 성북동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%84%B1%EB%B6%81%EB%8F%99-5878\n",
      "[87/100] 부산광역시 서구 아미동1가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%95%84%EB%AF%B8%EB%8F%991%EA%B0%80-5946\n",
      "[88/100] 부산광역시 서구 충무동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B6%A9%EB%AC%B4%EB%8F%992%EA%B0%80-5949\n",
      "[89/100] 부산광역시 서구 토성동4가 -> https://daangn.com/kr/buy-sell/s/?in=%ED%86%A0%EC%84%B1%EB%8F%994%EA%B0%80-5954\n",
      "[90/100] 부산광역시 강서구 봉림동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%B4%89%EB%A6%BC%EB%8F%99-5876\n",
      "[91/100] 부산광역시 영도구 신선동3가 -> https://daangn.com/kr/buy-sell/s/?in=%EC%8B%A0%EC%84%A0%EB%8F%993%EA%B0%80-5976\n",
      "[92/100] 부산광역시 영도구 남항동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%82%A8%ED%95%AD%EB%8F%992%EA%B0%80-5962\n",
      "[93/100] 부산광역시 강서구 식만동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%8B%9D%EB%A7%8C%EB%8F%99-5880\n",
      "[94/100] 부산광역시 강서구 죽동동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%A3%BD%EB%8F%99%EB%8F%99-5882\n",
      "[95/100] 부산광역시 강서구 천성동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%B2%9C%EC%84%B1%EB%8F%99-5885\n",
      "[96/100] 부산광역시 강서구 죽림동 -> https://daangn.com/kr/buy-sell/s/?in=%EC%A3%BD%EB%A6%BC%EB%8F%99-5883\n",
      "[97/100] 부산광역시 영도구 대평동2가 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%ED%8F%89%EB%8F%992%EA%B0%80-5967\n",
      "[98/100] 부산광역시 강서구 눌차동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%88%8C%EC%B0%A8%EB%8F%99-5870\n",
      "[99/100] 부산광역시 강서구 대항동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8C%80%ED%95%AD%EB%8F%99-5871\n",
      "[100/100] 부산광역시 강서구 동선동 -> https://daangn.com/kr/buy-sell/s/?in=%EB%8F%99%EC%84%A0%EB%8F%99-5872\n",
      "\n",
      "=== 모든 동네 링크 수집 완료! ===\n"
     ]
    }
   ],
   "source": [
    "import pandas as pd\n",
    "\n",
    "# 1. 수집된 결과(지역명, URL)를 저장할 리스트\n",
    "total_data = []\n",
    "\n",
    "# 2. 현재 열려있는 목록의 전체 개수 확인\n",
    "items = driver.find_elements(By.CSS_SELECTOR, \"ul._17gf2psm button._17gf2psp\")\n",
    "total_count = len(items)\n",
    "print(f\"총 {total_count}개의 동네 항목을 찾았습니다. 수집을 시작합니다...\\n\")\n",
    "\n",
    "# 열려있는 모달 일단 닫기 (ESC)\n",
    "driver.find_element(By.TAG_NAME, \"body\").send_keys(Keys.ESCAPE)\n",
    "time.sleep(1)\n",
    "\n",
    "# 3. 0번부터 끝까지 하나씩 반복문 실행\n",
    "for i in range(total_count):\n",
    "    try:\n",
    "        # [A] 필터 버튼(2번) 눌러서 모달 열기\n",
    "        btns = driver.find_elements(By.CSS_SELECTOR, \"button.seed-action-button\")\n",
    "        driver.execute_script(\"arguments[0].click();\", btns[2])\n",
    "        time.sleep(1)\n",
    "        \n",
    "        # [B] \"부산광역시\" 검색 입력\n",
    "        search_input = driver.find_element(By.CSS_SELECTOR, \"input[aria-label='지역 검색']\")\n",
    "        search_input.click()\n",
    "        search_input.send_keys(Keys.CONTROL + \"a\")\n",
    "        search_input.send_keys(Keys.BACKSPACE)\n",
    "        search_input.send_keys(\"부산광역시\")\n",
    "        time.sleep(1.5) # 검색 결과 로딩 대기\n",
    "        \n",
    "        # [C] i번째 동네 버튼 찾기 & 이름 추출\n",
    "        current_items = driver.find_elements(By.CSS_SELECTOR, \"ul._17gf2psm button._17gf2psp\")\n",
    "        target_btn = current_items[i]\n",
    "        region_name = target_btn.text.strip()\n",
    "        \n",
    "        # [D] i번째 동네 버튼 클릭!\n",
    "        driver.execute_script(\"arguments[0].click();\", target_btn)\n",
    "        time.sleep(2) # 페이지 및 주소(URL) 변경 대기\n",
    "        \n",
    "        # [E] 변경된 페이지의 URL 가져오기\n",
    "        current_url = driver.current_url\n",
    "        \n",
    "        print(f\"[{i+1}/{total_count}] {region_name} -> {current_url}\")\n",
    "        total_data.append([region_name, current_url])\n",
    "        \n",
    "    except Exception as e:\n",
    "        print(f\"[{i+1}번째 수집 중 오류 발생]: {e}\")\n",
    "        # 오류 발생 시 모달이 켜져 있으면 ESC로 닫고 다음으로 넘어가기\n",
    "        try:\n",
    "            driver.find_element(By.TAG_NAME, \"body\").send_keys(Keys.ESCAPE)\n",
    "        except:\n",
    "            pass\n",
    "        time.sleep(1)\n",
    "        continue\n",
    "\n",
    "print(\"\\n=== 모든 동네 링크 수집 완료! ===\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fc0975c9-8be7-4fa1-88de-dbcd86425486",
   "metadata": {},
   "source": [
    "## 6. 동네별 URL 데이터 저장\n",
    "\n",
    "앞 단계에서 수집한 부산광역시 동네별 URL 데이터를 `DataFrame`으로 변환한다.\n",
    "\n",
    "수집한 데이터는 `지역명`과 `URL` 컬럼으로 구성되어 있으며, 이후 중고거래 매물 수집 단계에서 각 URL을 순회하는 기준 데이터로 사용된다.\n",
    "\n",
    "또한 팀원들과 수집 결과를 확인하거나 재사용할 수 있도록 Excel 파일로 저장하였다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8639198e-4e7b-42da-8727-11e267ef74f7",
   "metadata": {},
   "outputs": [],
   "source": [
    "# DataFrame으로 변환 후 엑셀 저장\n",
    "df = pd.DataFrame(total_data, columns=['지역명', 'URL'])\n",
    "df.to_excel(\"당근_부산_동네별_링크.xlsx\", index=False)\n",
    "\n",
    "display(df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bf677e4c-2dd1-4322-9587-20926c082330",
   "metadata": {},
   "source": [
    "## 7. 동네별 중고거래 매물 데이터 수집\n",
    "\n",
    "이 단계에서는 앞에서 저장한 부산광역시 동네별 URL을 하나씩 방문하며 실제 중고거래 매물 정보를 수집한다.\n",
    "\n",
    "수집하는 주요 정보는 다음과 같다.\n",
    "\n",
    "| 컬럼명 | 설명 |\n",
    "|---|---|\n",
    "| 수집동네 | 매물이 수집된 부산 지역명 |\n",
    "| 제목 | 중고거래 상품명 |\n",
    "| 가격 | 판매자가 제시한 가격 |\n",
    "| 위치/시간 | 위치 또는 게시 시간 관련 정보 |\n",
    "| 게시글링크 | 원본 게시글 링크 |\n",
    "\n",
    "페이지 내 상품 목록은 스크롤을 내려야 추가로 로딩될 수 있으므로, 각 동네 페이지에서 여러 번 스크롤을 수행한다.  \n",
    "이후 페이지의 HTML을 `BeautifulSoup`으로 파싱하고, 상품 상세 링크에 해당하는 요소만 추출한다.\n",
    "\n",
    "제목과 가격은 HTML 구조가 일정하지 않을 수 있으므로, 별도의 함수로 추출하였다.  \n",
    "가격은 `원`, `무료`, `나눔` 등의 표현을 기준으로 찾고, 상품 링크는 실제 상품 상세 페이지에 해당하는 URL만 저장하였다.\n",
    "\n",
    "수집된 데이터는 최종적으로 Excel 파일로 저장되며, 이후 전처리, EDA, 가격 예측 모델링, LLM 다국어 설명 기능에 활용된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "id": "5441f406-dc8c-460a-ba02-d62a6f442090",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "진행상황: [100/100] '부산광역시 강서구 동선동' 수집 완료!\n",
      "- 이 동네에서 수집한 물건: 171개\n",
      "- 현재까지 누적된 전체 물건: 29213개\n",
      "\n",
      "=== 모든 동네 스크래핑 완료! ===\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>수집동네</th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>위치/시간</th>\n",
       "      <th>게시글링크</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>컴퓨터</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>제습기</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>아이폰</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>자전거</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>노트북</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>...</th>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>29208</th>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>거래완료</td>\n",
       "      <td>374,000원</td>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%ED%99%88%E...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>29209</th>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>거래완료</td>\n",
       "      <td>45,000원</td>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EC%97%90%E...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>29210</th>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>거래완료</td>\n",
       "      <td>나눔</td>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EC%B9%B4%E...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>29211</th>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>거래완료</td>\n",
       "      <td>무료</td>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EB%AF%B8%E...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>29212</th>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>거래완료</td>\n",
       "      <td>나눔</td>\n",
       "      <td>부산광역시 강서구 동선동</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EB%B0%B1%E...</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "<p>29213 rows × 5 columns</p>\n",
       "</div>"
      ],
      "text/plain": [
       "                수집동네    제목        가격          위치/시간  \\\n",
       "0      부산광역시 기장군 정관읍   컴퓨터     가격 없음  부산광역시 기장군 정관읍   \n",
       "1      부산광역시 기장군 정관읍   제습기     가격 없음  부산광역시 기장군 정관읍   \n",
       "2      부산광역시 기장군 정관읍   아이폰     가격 없음  부산광역시 기장군 정관읍   \n",
       "3      부산광역시 기장군 정관읍   자전거     가격 없음  부산광역시 기장군 정관읍   \n",
       "4      부산광역시 기장군 정관읍   노트북     가격 없음  부산광역시 기장군 정관읍   \n",
       "...              ...   ...       ...            ...   \n",
       "29208  부산광역시 강서구 동선동  거래완료  374,000원  부산광역시 강서구 동선동   \n",
       "29209  부산광역시 강서구 동선동  거래완료   45,000원  부산광역시 강서구 동선동   \n",
       "29210  부산광역시 강서구 동선동  거래완료        나눔  부산광역시 강서구 동선동   \n",
       "29211  부산광역시 강서구 동선동  거래완료        무료  부산광역시 강서구 동선동   \n",
       "29212  부산광역시 강서구 동선동  거래완료        나눔  부산광역시 강서구 동선동   \n",
       "\n",
       "                                                   게시글링크  \n",
       "0      https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "1      https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "2      https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "3      https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "4      https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "...                                                  ...  \n",
       "29208  https://www.daangn.com/kr/buy-sell/%ED%99%88%E...  \n",
       "29209  https://www.daangn.com/kr/buy-sell/%EC%97%90%E...  \n",
       "29210  https://www.daangn.com/kr/buy-sell/%EC%B9%B4%E...  \n",
       "29211  https://www.daangn.com/kr/buy-sell/%EB%AF%B8%E...  \n",
       "29212  https://www.daangn.com/kr/buy-sell/%EB%B0%B1%E...  \n",
       "\n",
       "[29213 rows x 5 columns]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import re\n",
    "import time\n",
    "import pandas as pd\n",
    "from bs4 import BeautifulSoup\n",
    "from selenium.webdriver.common.by import By\n",
    "from selenium.webdriver.support.ui import WebDriverWait\n",
    "from selenium.webdriver.support import expected_conditions as EC\n",
    "\n",
    "try:\n",
    "    from IPython.display import clear_output, display\n",
    "except ImportError:\n",
    "    clear_output = lambda wait=True: None\n",
    "    display = print\n",
    "\n",
    "# ==========================================\n",
    "# 1. 설정\n",
    "# ==========================================\n",
    "MAX_ITEMS_PER_REGION = 300\n",
    "EXCEL_FILENAME = \"당근마켓_부산_동네별_중고물건_전체.xlsx\"\n",
    "BASE_URL = \"https://www.daangn.com\"\n",
    "\n",
    "# 속도 조절값: 너무 빠르게 해서 누락이 생기면 0.8 -> 1.2 정도로 올리세요.\n",
    "PAGE_LOAD_WAIT = 1.0\n",
    "SCROLL_WAIT = 0.8\n",
    "MAX_STABLE_SCROLLS = 3\n",
    "\n",
    "wait = WebDriverWait(driver, 7)\n",
    "\n",
    "try:\n",
    "    url_list = df[[\"지역명\", \"URL\"]].values.tolist()\n",
    "except NameError:\n",
    "    df_excel = pd.read_excel(\"당근_부산_동네별_링크.xlsx\")\n",
    "    url_list = df_excel[[\"지역명\", \"URL\"]].values.tolist()\n",
    "\n",
    "all_products = []\n",
    "\n",
    "# 실제 상품 상세 링크만 통과시킨다.\n",
    "# 제외 예: /kr/buy-sell/s/, /kr/buy-sell/?in=...\n",
    "PRODUCT_PATH_RE = re.compile(r\"^/kr/buy-sell/(?!s/?$|all/?$|\\?)([^/?#]+)\")\n",
    "\n",
    "\n",
    "def absolute_url(href):\n",
    "    if not href:\n",
    "        return \"\"\n",
    "    if href.startswith(\"http\"):\n",
    "        return href\n",
    "    return BASE_URL + href\n",
    "\n",
    "\n",
    "def is_product_url(url):\n",
    "    path = url.replace(BASE_URL, \"\")\n",
    "    return bool(PRODUCT_PATH_RE.search(path))\n",
    "\n",
    "\n",
    "def first_text(root, selectors):\n",
    "    for selector in selectors:\n",
    "        elem = root.select_one(selector)\n",
    "        if elem:\n",
    "            text = elem.get_text(\" \", strip=True)\n",
    "            if text:\n",
    "                return text\n",
    "    return \"\"\n",
    "\n",
    "\n",
    "def extract_title_price(anchor):\n",
    "    title = first_text(\n",
    "        anchor,\n",
    "        [\n",
    "            \"[data-testid*='title']\",\n",
    "            \"[class*='title']\",\n",
    "            \"h2\",\n",
    "            \"h3\",\n",
    "            \"span\",\n",
    "        ],\n",
    "    )\n",
    "    price = first_text(\n",
    "        anchor,\n",
    "        [\n",
    "            \"[data-testid*='price']\",\n",
    "            \"[class*='price']\",\n",
    "            \"data\",\n",
    "            \"strong\",\n",
    "        ],\n",
    "    )\n",
    "\n",
    "    full_text = anchor.get_text(\" \", strip=True)\n",
    "\n",
    "    if not price:\n",
    "        price_match = re.search(r\"(나눔|무료|가격없음|[\\d,]+\\s*원)\", full_text)\n",
    "        price = price_match.group(1) if price_match else \"가격 없음\"\n",
    "\n",
    "    if not title or title == price:\n",
    "        parts = [part.strip() for part in full_text.split() if part.strip()]\n",
    "        title = parts[0] if parts else \"제목 없음\"\n",
    "\n",
    "    return title, price\n",
    "\n",
    "\n",
    "def get_product_count_from_dom():\n",
    "    hrefs = driver.execute_script(\n",
    "        \"\"\"\n",
    "        return Array.from(document.querySelectorAll(\"a[href*='/kr/buy-sell/']\"))\n",
    "            .map(a => a.getAttribute('href'));\n",
    "        \"\"\"\n",
    "    )\n",
    "    links = [absolute_url(href) for href in hrefs]\n",
    "    return len({link for link in links if is_product_url(link)})\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 2. 동네별 URL 순회 수집\n",
    "# ==========================================\n",
    "for idx, (region_name, target_url) in enumerate(url_list):\n",
    "    print(f\"[{idx + 1}/{len(url_list)}] '{region_name}' 접속 중: {target_url}\")\n",
    "    driver.get(target_url)\n",
    "\n",
    "    try:\n",
    "        wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, \"a[href*='/kr/buy-sell/']\")))\n",
    "        time.sleep(PAGE_LOAD_WAIT)\n",
    "    except Exception:\n",
    "        print(f\"  └ [경고] '{region_name}' 페이지에서 게시글 로딩 시간이 초과되었습니다.\")\n",
    "\n",
    "    stable_scrolls = 0\n",
    "    last_count = 0\n",
    "\n",
    "    while True:\n",
    "        current_count = get_product_count_from_dom()\n",
    "\n",
    "        if current_count >= MAX_ITEMS_PER_REGION:\n",
    "            break\n",
    "\n",
    "        if current_count == last_count:\n",
    "            stable_scrolls += 1\n",
    "            if stable_scrolls >= MAX_STABLE_SCROLLS:\n",
    "                break\n",
    "        else:\n",
    "            stable_scrolls = 0\n",
    "            last_count = current_count\n",
    "\n",
    "        driver.execute_script(\"window.scrollTo(0, document.body.scrollHeight);\")\n",
    "        time.sleep(SCROLL_WAIT)\n",
    "\n",
    "        try:\n",
    "            more_btns = driver.find_elements(\n",
    "                By.XPATH,\n",
    "                \"//button[contains(., '더보기')] | //a[contains(., '더보기')]\",\n",
    "            )\n",
    "            for btn in more_btns:\n",
    "                if btn.is_displayed():\n",
    "                    driver.execute_script(\"arguments[0].click();\", btn)\n",
    "                    time.sleep(SCROLL_WAIT)\n",
    "                    break\n",
    "        except Exception:\n",
    "            pass\n",
    "\n",
    "    # 마지막에 한 번만 HTML 파싱한다. 이게 기존 코드보다 훨씬 빠른 부분입니다.\n",
    "    soup = BeautifulSoup(driver.page_source, \"html.parser\")\n",
    "    anchors = soup.select(\"a[href*='/kr/buy-sell/']\")\n",
    "\n",
    "    visited_links = set()\n",
    "    collected_in_region = 0\n",
    "\n",
    "    for anchor in anchors:\n",
    "        if collected_in_region >= MAX_ITEMS_PER_REGION:\n",
    "            break\n",
    "\n",
    "        link = absolute_url(anchor.get(\"href\", \"\"))\n",
    "        if not is_product_url(link) or link in visited_links:\n",
    "            continue\n",
    "        visited_links.add(link)\n",
    "\n",
    "        title, price = extract_title_price(anchor)\n",
    "\n",
    "        all_products.append(\n",
    "            {\n",
    "                \"수집동네\": region_name,\n",
    "                \"제목\": title,\n",
    "                \"가격\": price,\n",
    "                \"위치/시간\": region_name,\n",
    "                \"게시글링크\": link,\n",
    "            }\n",
    "        )\n",
    "        collected_in_region += 1\n",
    "\n",
    "    clear_output(wait=True)\n",
    "    print(f\"진행상황: [{idx + 1}/{len(url_list)}] '{region_name}' 수집 완료!\")\n",
    "    print(f\"- 이 동네에서 수집한 물건: {collected_in_region}개\")\n",
    "    print(f\"- 현재까지 누적된 전체 물건: {len(all_products)}개\\n\")\n",
    "\n",
    "    result_df = pd.DataFrame(all_products)\n",
    "    result_df.to_excel(EXCEL_FILENAME, index=False)\n",
    "\n",
    "print(\"=== 모든 동네 스크래핑 완료! ===\")\n",
    "if all_products:\n",
    "    display(result_df)\n",
    "else:\n",
    "    print(\"수집된 데이터가 없습니다. 상품 링크 선택자와 페이지 로딩 상태를 확인해 주세요.\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "07ee48fd-eca2-49a6-961e-fa91995fd630",
   "metadata": {},
   "source": [
    "# 데이터 전처리\n",
    "\n",
    "## 8. 수집 데이터 불러오기\n",
    "\n",
    "앞 단계에서 수집한 중고거래 매물 데이터를 불러온다.\n",
    "\n",
    "수집된 데이터는 웹페이지에서 가져온 원본 데이터이므로, 분석과 머신러닝에 바로 사용하기 어렵다.  \n",
    "따라서 전처리 과정을 통해 결측치, 중복 데이터, 가격 문자열, 이상치 등을 정리한다.\n",
    "\n",
    "전처리의 주요 목표는 다음과 같다.\n",
    "\n",
    "1. 수집된 데이터 구조 확인\n",
    "2. 제목과 가격이 없는 데이터 제거\n",
    "3. 중복 게시글 제거\n",
    "4. 문자열 가격을 숫자형 가격으로 변환\n",
    "5. 무료나눔 상품 분리\n",
    "6. 이상치 처리\n",
    "7. 상품명 기반 카테고리 생성\n",
    "8. 전처리 완료 데이터 저장"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "2a5177ae-c476-4010-834d-3001f643d26c",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "원본 데이터 크기: (29213, 5)\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>수집동네</th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>위치/시간</th>\n",
       "      <th>게시글링크</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>컴퓨터</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>제습기</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>아이폰</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>자전거</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>노트북</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            수집동네   제목     가격          위치/시간  \\\n",
       "0  부산광역시 기장군 정관읍  컴퓨터  가격 없음  부산광역시 기장군 정관읍   \n",
       "1  부산광역시 기장군 정관읍  제습기  가격 없음  부산광역시 기장군 정관읍   \n",
       "2  부산광역시 기장군 정관읍  아이폰  가격 없음  부산광역시 기장군 정관읍   \n",
       "3  부산광역시 기장군 정관읍  자전거  가격 없음  부산광역시 기장군 정관읍   \n",
       "4  부산광역시 기장군 정관읍  노트북  가격 없음  부산광역시 기장군 정관읍   \n",
       "\n",
       "                                               게시글링크  \n",
       "0  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "1  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "2  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "3  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "4  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  "
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# 수집 완료 파일 불러오기\n",
    "raw_df = pd.read_excel(\"당근마켓_부산_동네별_중고물건_전체.xlsx\")\n",
    "\n",
    "print(\"원본 데이터 크기:\", raw_df.shape)\n",
    "raw_df.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5594023c-124c-4d9e-a91d-7681c2deb661",
   "metadata": {},
   "source": [
    "## 9. 데이터 기본 정보 확인\n",
    "\n",
    "전처리를 시작하기 전에 데이터의 기본 정보를 확인한다.\n",
    "\n",
    "확인할 내용은 다음과 같다.\n",
    "\n",
    "- 전체 데이터 개수\n",
    "- 컬럼별 데이터 타입\n",
    "- 결측치 개수\n",
    "- 중복 데이터 개수\n",
    "\n",
    "이 과정을 통해 어떤 컬럼에 문제가 있는지, 어떤 전처리가 필요한지 판단할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "8e842d70-9f86-4dce-86f7-d5f91201d284",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "<class 'pandas.core.frame.DataFrame'>\n",
      "RangeIndex: 29213 entries, 0 to 29212\n",
      "Data columns (total 5 columns):\n",
      " #   Column  Non-Null Count  Dtype \n",
      "---  ------  --------------  ----- \n",
      " 0   수집동네    29213 non-null  object\n",
      " 1   제목      29213 non-null  object\n",
      " 2   가격      29213 non-null  object\n",
      " 3   위치/시간   29213 non-null  object\n",
      " 4   게시글링크   29213 non-null  object\n",
      "dtypes: object(5)\n",
      "memory usage: 1.1+ MB\n"
     ]
    }
   ],
   "source": [
    "raw_df.info()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "1b4d463b-c408-433d-8c9b-9c07c5d75e68",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "수집동네     0\n",
       "제목       0\n",
       "가격       0\n",
       "위치/시간    0\n",
       "게시글링크    0\n",
       "dtype: int64"
      ]
     },
     "execution_count": 5,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "raw_df.isnull().sum()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "ee08b3f6-99f5-4817-9983-14f2a3b5aec8",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "np.int64(0)"
      ]
     },
     "execution_count": 6,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "raw_df.duplicated().sum()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "64981e30-84ae-4663-86f2-c2e1500f703a",
   "metadata": {},
   "source": [
    "## 10. 분석용 데이터 복사\n",
    "\n",
    "원본 데이터는 보존하고, 전처리를 위한 새로운 데이터프레임을 생성한다.\n",
    "\n",
    "이렇게 하면 전처리 과정에서 실수가 발생하더라도 원본 데이터를 다시 불러와 확인할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "29407b19-4aad-448c-9ef2-260f36dd2d8f",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>수집동네</th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>위치/시간</th>\n",
       "      <th>게시글링크</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>컴퓨터</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>제습기</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>아이폰</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>자전거</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>노트북</td>\n",
       "      <td>가격 없음</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            수집동네   제목     가격          위치/시간  \\\n",
       "0  부산광역시 기장군 정관읍  컴퓨터  가격 없음  부산광역시 기장군 정관읍   \n",
       "1  부산광역시 기장군 정관읍  제습기  가격 없음  부산광역시 기장군 정관읍   \n",
       "2  부산광역시 기장군 정관읍  아이폰  가격 없음  부산광역시 기장군 정관읍   \n",
       "3  부산광역시 기장군 정관읍  자전거  가격 없음  부산광역시 기장군 정관읍   \n",
       "4  부산광역시 기장군 정관읍  노트북  가격 없음  부산광역시 기장군 정관읍   \n",
       "\n",
       "                                               게시글링크  \n",
       "0  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "1  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "2  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "3  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "4  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  "
      ]
     },
     "execution_count": 7,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df_clean = raw_df.copy()\n",
    "\n",
    "df_clean.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dc80da7b-09e9-4f60-abb2-3df309b8b567",
   "metadata": {},
   "source": [
    "## 11. 결측치 및 비정상 값 처리\n",
    "\n",
    "수집 과정에서 제목이나 가격이 정상적으로 추출되지 않은 데이터가 포함될 수 있다.\n",
    "\n",
    "본 프로젝트에서 상품명과 가격은 가격 예측 모델에 반드시 필요한 핵심 정보이다.  \n",
    "따라서 `제목 없음`, `가격 없음`, 빈 문자열과 같은 값은 결측치로 처리한 뒤 제거한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "14d634b2-e6c0-4834-bed7-f4cac7f72d87",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "결측치 처리 전:\n",
      "수집동네        0\n",
      "제목          0\n",
      "가격       5905\n",
      "위치/시간       0\n",
      "게시글링크       0\n",
      "dtype: int64\n",
      "결측치 제거 후 데이터 크기: (23308, 5)\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>수집동네</th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>위치/시간</th>\n",
       "      <th>게시글링크</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>34</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>나눔</td>\n",
       "      <td>나눔</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>59</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>5,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>60</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>61</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>20,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>63</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>아동</td>\n",
       "      <td>3,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EC%95%84%E...</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             수집동네          제목       가격          위치/시간  \\\n",
       "34  부산광역시 기장군 정관읍          나눔       나눔  부산광역시 기장군 정관읍   \n",
       "59  부산광역시 기장군 정관읍   5,000원 이하   5,000원  부산광역시 기장군 정관읍   \n",
       "60  부산광역시 기장군 정관읍  10,000원 이하  10,000원  부산광역시 기장군 정관읍   \n",
       "61  부산광역시 기장군 정관읍  20,000원 이하  20,000원  부산광역시 기장군 정관읍   \n",
       "63  부산광역시 기장군 정관읍          아동   3,000원  부산광역시 기장군 정관읍   \n",
       "\n",
       "                                                게시글링크  \n",
       "34  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "59  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "60  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "61  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...  \n",
       "63  https://www.daangn.com/kr/buy-sell/%EC%95%84%E...  "
      ]
     },
     "execution_count": 8,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "import numpy as np\n",
    "\n",
    "# 문자열 공백 제거\n",
    "for col in df_clean.columns:\n",
    "    if df_clean[col].dtype == \"object\":\n",
    "        df_clean[col] = df_clean[col].astype(str).str.strip()\n",
    "\n",
    "# 비정상 값 결측치 처리\n",
    "df_clean[\"제목\"] = df_clean[\"제목\"].replace([\"제목 없음\", \"nan\", \"\"], np.nan)\n",
    "df_clean[\"가격\"] = df_clean[\"가격\"].replace([\"가격 없음\", \"nan\", \"\"], np.nan)\n",
    "df_clean[\"게시글링크\"] = df_clean[\"게시글링크\"].replace([\"nan\", \"\"], np.nan)\n",
    "\n",
    "print(\"결측치 처리 전:\")\n",
    "print(df_clean.isnull().sum())\n",
    "\n",
    "# 핵심 컬럼 결측치 제거\n",
    "df_clean = df_clean.dropna(subset=[\"제목\", \"가격\", \"게시글링크\"])\n",
    "\n",
    "print(\"결측치 제거 후 데이터 크기:\", df_clean.shape)\n",
    "df_clean.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3bd78b9-bec0-46e9-a6e8-180b71b1fa4c",
   "metadata": {},
   "source": [
    "## 12. 중복 게시글 제거\n",
    "\n",
    "동네별 URL을 순회하며 데이터를 수집하는 과정에서 같은 상품이 여러 번 수집될 수 있다.\n",
    "\n",
    "게시글 링크는 각 상품을 구분하는 고유한 값으로 볼 수 있으므로,  \n",
    "`게시글링크` 컬럼을 기준으로 중복 데이터를 제거한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "21376618-8d5a-4e76-a918-338251f7c233",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "중복 제거 전: 23308\n",
      "중복 제거 후: 23308\n",
      "제거된 중복 개수: 0\n"
     ]
    }
   ],
   "source": [
    "before_count = len(df_clean)\n",
    "\n",
    "df_clean = df_clean.drop_duplicates(subset=[\"게시글링크\"])\n",
    "\n",
    "after_count = len(df_clean)\n",
    "\n",
    "print(\"중복 제거 전:\", before_count)\n",
    "print(\"중복 제거 후:\", after_count)\n",
    "print(\"제거된 중복 개수:\", before_count - after_count)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "563a81f5-7ef8-44a6-9186-e3af9fea1afb",
   "metadata": {},
   "source": [
    "## 13. 가격 데이터 숫자형 변환\n",
    "\n",
    "수집된 가격 데이터는 `30,000원`, `100,000원`, `나눔`, `무료`처럼 문자열 형태로 저장되어 있다.\n",
    "\n",
    "머신러닝 모델은 문자열 데이터를 가격으로 인식할 수 없기 때문에, 가격 컬럼을 숫자형으로 변환해야 한다.\n",
    "\n",
    "처리 기준은 다음과 같다.\n",
    "\n",
    "- `나눔`, `무료`, `무료나눔`은 0원으로 처리\n",
    "- 숫자와 쉼표가 포함된 가격은 숫자만 추출\n",
    "- 숫자가 없는 경우 결측치로 처리"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "52175b3b-b76d-422f-a478-460d1f66ccba",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>가격_numeric</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>34</th>\n",
       "      <td>나눔</td>\n",
       "      <td>나눔</td>\n",
       "      <td>0.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>59</th>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>5,000원</td>\n",
       "      <td>5000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>60</th>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>10000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>61</th>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>20,000원</td>\n",
       "      <td>20000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>63</th>\n",
       "      <td>아동</td>\n",
       "      <td>3,000원</td>\n",
       "      <td>3000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>64</th>\n",
       "      <td>스피커</td>\n",
       "      <td>175,000원</td>\n",
       "      <td>175000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>65</th>\n",
       "      <td>선글라스</td>\n",
       "      <td>12,000원</td>\n",
       "      <td>12000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>66</th>\n",
       "      <td>전동드릴</td>\n",
       "      <td>28,000원</td>\n",
       "      <td>28000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>67</th>\n",
       "      <td>맘바디</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>10000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>68</th>\n",
       "      <td>이케아</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>10000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>69</th>\n",
       "      <td>마롤로뜨</td>\n",
       "      <td>15,000원</td>\n",
       "      <td>15000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>70</th>\n",
       "      <td>하이엔</td>\n",
       "      <td>5,000원</td>\n",
       "      <td>5000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>71</th>\n",
       "      <td>크록스</td>\n",
       "      <td>15,000원</td>\n",
       "      <td>15000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>72</th>\n",
       "      <td>쿠진아트</td>\n",
       "      <td>100,000원</td>\n",
       "      <td>100000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>73</th>\n",
       "      <td>블루마린</td>\n",
       "      <td>50,000원</td>\n",
       "      <td>50000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>74</th>\n",
       "      <td>해피텐</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>10000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>75</th>\n",
       "      <td>시공주니어</td>\n",
       "      <td>250,000원</td>\n",
       "      <td>250000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>76</th>\n",
       "      <td>히포엔비</td>\n",
       "      <td>5,000원</td>\n",
       "      <td>5000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>77</th>\n",
       "      <td>낚시릴</td>\n",
       "      <td>30,000원</td>\n",
       "      <td>30000.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>78</th>\n",
       "      <td>4단</td>\n",
       "      <td>7,000원</td>\n",
       "      <td>7000.0</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            제목        가격  가격_numeric\n",
       "34          나눔        나눔         0.0\n",
       "59   5,000원 이하    5,000원      5000.0\n",
       "60  10,000원 이하   10,000원     10000.0\n",
       "61  20,000원 이하   20,000원     20000.0\n",
       "63          아동    3,000원      3000.0\n",
       "64         스피커  175,000원    175000.0\n",
       "65        선글라스   12,000원     12000.0\n",
       "66        전동드릴   28,000원     28000.0\n",
       "67         맘바디   10,000원     10000.0\n",
       "68         이케아   10,000원     10000.0\n",
       "69        마롤로뜨   15,000원     15000.0\n",
       "70         하이엔    5,000원      5000.0\n",
       "71         크록스   15,000원     15000.0\n",
       "72        쿠진아트  100,000원    100000.0\n",
       "73        블루마린   50,000원     50000.0\n",
       "74         해피텐   10,000원     10000.0\n",
       "75       시공주니어  250,000원    250000.0\n",
       "76        히포엔비    5,000원      5000.0\n",
       "77         낚시릴   30,000원     30000.0\n",
       "78          4단    7,000원      7000.0"
      ]
     },
     "execution_count": 12,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "import re\n",
    "import numpy as np\n",
    "\n",
    "def clean_price(price):\n",
    "    if pd.isna(price):\n",
    "        return np.nan\n",
    "    \n",
    "    price = str(price).strip()\n",
    "    \n",
    "    if \"나눔\" in price or \"무료\" in price:\n",
    "        return 0\n",
    "    \n",
    "    numbers = re.sub(r\"[^0-9]\", \"\", price)\n",
    "    \n",
    "    if numbers == \"\":\n",
    "        return np.nan\n",
    "    \n",
    "    return int(numbers)\n",
    "\n",
    "df_clean[\"가격_numeric\"] = df_clean[\"가격\"].apply(clean_price)\n",
    "\n",
    "df_clean[[\"제목\", \"가격\", \"가격_numeric\"]].head(20)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "69acdcdd-09eb-4805-9f08-385501927c7c",
   "metadata": {},
   "source": [
    "## 14. 가격 변환 실패 데이터 제거\n",
    "\n",
    "가격 문자열을 숫자로 변환한 뒤에도 일부 데이터는 숫자로 바뀌지 않을 수 있다.\n",
    "\n",
    "예를 들어 가격 정보가 비어 있거나 숫자가 포함되지 않은 경우에는 가격 예측 모델에 사용할 수 없다.  \n",
    "따라서 `가격_numeric`이 결측치인 데이터는 제거한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "6b54c75a-64b2-44f5-92dc-dd6b4dfd9f8f",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "가격 변환 실패 개수: 2\n",
      "가격 전처리 후 데이터 크기: (23306, 6)\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>수집동네</th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>위치/시간</th>\n",
       "      <th>게시글링크</th>\n",
       "      <th>가격_numeric</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>34</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>나눔</td>\n",
       "      <td>나눔</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>59</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>5,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>5000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>60</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>61</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>20,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>20000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>63</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>아동</td>\n",
       "      <td>3,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EC%95%84%E...</td>\n",
       "      <td>3000</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             수집동네          제목       가격          위치/시간  \\\n",
       "34  부산광역시 기장군 정관읍          나눔       나눔  부산광역시 기장군 정관읍   \n",
       "59  부산광역시 기장군 정관읍   5,000원 이하   5,000원  부산광역시 기장군 정관읍   \n",
       "60  부산광역시 기장군 정관읍  10,000원 이하  10,000원  부산광역시 기장군 정관읍   \n",
       "61  부산광역시 기장군 정관읍  20,000원 이하  20,000원  부산광역시 기장군 정관읍   \n",
       "63  부산광역시 기장군 정관읍          아동   3,000원  부산광역시 기장군 정관읍   \n",
       "\n",
       "                                                게시글링크  가격_numeric  \n",
       "34  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...           0  \n",
       "59  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...        5000  \n",
       "60  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...       10000  \n",
       "61  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...       20000  \n",
       "63  https://www.daangn.com/kr/buy-sell/%EC%95%84%E...        3000  "
      ]
     },
     "execution_count": 13,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "print(\"가격 변환 실패 개수:\", df_clean[\"가격_numeric\"].isnull().sum())\n",
    "\n",
    "df_clean = df_clean.dropna(subset=[\"가격_numeric\"])\n",
    "df_clean[\"가격_numeric\"] = df_clean[\"가격_numeric\"].astype(int)\n",
    "\n",
    "print(\"가격 전처리 후 데이터 크기:\", df_clean.shape)\n",
    "df_clean.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "06b157a7-ab81-4ae8-9f87-bbbd99d8d41b",
   "metadata": {},
   "source": [
    "## 15. 무료나눔 상품 분리\n",
    "\n",
    "가격이 0원인 상품은 무료나눔 상품으로 볼 수 있다.\n",
    "\n",
    "무료나눔 상품은 유학생에게 유용한 정보일 수 있지만, 가격 예측 모델 학습에는 적합하지 않다.  \n",
    "따라서 무료나눔 상품은 별도로 분리하고, 가격 예측 모델에는 유료 상품만 사용한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "0916a117-1b37-4e60-9584-f460339c720d",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "무료나눔 상품 수: 1029\n",
      "유료 상품 수: 22277\n"
     ]
    }
   ],
   "source": [
    "free_items = df_clean[df_clean[\"가격_numeric\"] == 0].copy()\n",
    "paid_items = df_clean[df_clean[\"가격_numeric\"] > 0].copy()\n",
    "\n",
    "print(\"무료나눔 상품 수:\", len(free_items))\n",
    "print(\"유료 상품 수:\", len(paid_items))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8aebc475-b4d9-42e2-9108-e19362b7ac8f",
   "metadata": {},
   "source": [
    "## 16. 가격 이상치 확인\n",
    "\n",
    "중고거래 데이터에는 매우 저렴한 상품부터 고가의 상품까지 다양한 가격대가 존재한다.\n",
    "\n",
    "하지만 일부 지나치게 높은 가격의 상품은 전체 가격 예측 모델에 영향을 줄 수 있다.  \n",
    "따라서 유료 상품의 최소값, 최대값, 평균, 중앙값을 확인하여 가격 분포를 파악한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "7765999f-c257-4bcf-825e-a073bf59d6aa",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "최소 가격: 1\n",
      "최대 가격: 999999999\n",
      "평균 가격: 389843.77\n",
      "중앙값: 20000.0\n"
     ]
    }
   ],
   "source": [
    "print(\"최소 가격:\", paid_items[\"가격_numeric\"].min())\n",
    "print(\"최대 가격:\", paid_items[\"가격_numeric\"].max())\n",
    "print(\"평균 가격:\", round(paid_items[\"가격_numeric\"].mean(), 2))\n",
    "print(\"중앙값:\", paid_items[\"가격_numeric\"].median())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3a0cdd5c-50b5-495b-99d2-e5df1de761b7",
   "metadata": {},
   "source": [
    "## 17. 상위 1% 가격 이상치 제거\n",
    "\n",
    "가격이 지나치게 높은 일부 상품은 일반적인 중고거래 가격 예측 모델을 왜곡할 수 있다.\n",
    "\n",
    "따라서 본 프로젝트에서는 상위 1%에 해당하는 가격을 이상치로 판단하고 제거하였다.  \n",
    "이를 통해 일반적인 중고거래 상품 가격대에 맞는 모델을 만들 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "f4ea7c88-3a16-4fac-8e4a-eeeb5230b64c",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "상위 1% 기준 가격: 2200000\n",
      "이상치 제거 전: (22277, 6)\n",
      "이상치 제거 후: (22056, 6)\n"
     ]
    }
   ],
   "source": [
    "upper_limit = paid_items[\"가격_numeric\"].quantile(0.99)\n",
    "\n",
    "df_model = paid_items[paid_items[\"가격_numeric\"] <= upper_limit].copy()\n",
    "\n",
    "print(\"상위 1% 기준 가격:\", int(upper_limit))\n",
    "print(\"이상치 제거 전:\", paid_items.shape)\n",
    "print(\"이상치 제거 후:\", df_model.shape)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7acb43d8-e22e-4dbc-8cb1-090b3f7740dc",
   "metadata": {},
   "source": [
    "## 18. 상품명 기반 카테고리 생성\n",
    "\n",
    "현재 수집 데이터에는 상품 카테고리 정보가 없으므로, 상품명에 포함된 키워드를 기준으로 간단한 카테고리를 생성한다.\n",
    "\n",
    "예를 들어 `아이폰`, `노트북`, `에어팟`이 포함되면 전자기기로 분류하고,  \n",
    "`책상`, `의자`, `침대`가 포함되면 가구로 분류한다.\n",
    "\n",
    "이 카테고리 변수는 이후 EDA와 머신러닝 가격 예측 모델의 입력 변수로 사용된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "9fcbb2f4-bdd1-4c8e-8c61-b4ccfd67e51a",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>가격_numeric</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>59</th>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>5000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>60</th>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>61</th>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>20000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>63</th>\n",
       "      <td>아동</td>\n",
       "      <td>기타</td>\n",
       "      <td>3000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>64</th>\n",
       "      <td>스피커</td>\n",
       "      <td>기타</td>\n",
       "      <td>175000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>65</th>\n",
       "      <td>선글라스</td>\n",
       "      <td>기타</td>\n",
       "      <td>12000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>66</th>\n",
       "      <td>전동드릴</td>\n",
       "      <td>기타</td>\n",
       "      <td>28000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>67</th>\n",
       "      <td>맘바디</td>\n",
       "      <td>기타</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>68</th>\n",
       "      <td>이케아</td>\n",
       "      <td>기타</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>69</th>\n",
       "      <td>마롤로뜨</td>\n",
       "      <td>기타</td>\n",
       "      <td>15000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>70</th>\n",
       "      <td>하이엔</td>\n",
       "      <td>기타</td>\n",
       "      <td>5000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>71</th>\n",
       "      <td>크록스</td>\n",
       "      <td>기타</td>\n",
       "      <td>15000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>72</th>\n",
       "      <td>쿠진아트</td>\n",
       "      <td>기타</td>\n",
       "      <td>100000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>73</th>\n",
       "      <td>블루마린</td>\n",
       "      <td>기타</td>\n",
       "      <td>50000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>74</th>\n",
       "      <td>해피텐</td>\n",
       "      <td>기타</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>75</th>\n",
       "      <td>시공주니어</td>\n",
       "      <td>기타</td>\n",
       "      <td>250000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>76</th>\n",
       "      <td>히포엔비</td>\n",
       "      <td>기타</td>\n",
       "      <td>5000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>77</th>\n",
       "      <td>낚시릴</td>\n",
       "      <td>기타</td>\n",
       "      <td>30000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>78</th>\n",
       "      <td>4단</td>\n",
       "      <td>기타</td>\n",
       "      <td>7000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>79</th>\n",
       "      <td>까스텔바작</td>\n",
       "      <td>기타</td>\n",
       "      <td>20000</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            제목 카테고리  가격_numeric\n",
       "59   5,000원 이하   기타        5000\n",
       "60  10,000원 이하   기타       10000\n",
       "61  20,000원 이하   기타       20000\n",
       "63          아동   기타        3000\n",
       "64         스피커   기타      175000\n",
       "65        선글라스   기타       12000\n",
       "66        전동드릴   기타       28000\n",
       "67         맘바디   기타       10000\n",
       "68         이케아   기타       10000\n",
       "69        마롤로뜨   기타       15000\n",
       "70         하이엔   기타        5000\n",
       "71         크록스   기타       15000\n",
       "72        쿠진아트   기타      100000\n",
       "73        블루마린   기타       50000\n",
       "74         해피텐   기타       10000\n",
       "75       시공주니어   기타      250000\n",
       "76        히포엔비   기타        5000\n",
       "77         낚시릴   기타       30000\n",
       "78          4단   기타        7000\n",
       "79       까스텔바작   기타       20000"
      ]
     },
     "execution_count": 18,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def make_category(title):\n",
    "    title = str(title).lower()\n",
    "    \n",
    "    if any(word in title for word in [\"아이폰\", \"갤럭시\", \"폰\", \"휴대폰\", \"노트북\", \"맥북\", \"아이패드\", \"에어팟\", \"버즈\", \"컴퓨터\", \"모니터\"]):\n",
    "        return \"전자기기\"\n",
    "    elif any(word in title for word in [\"책상\", \"의자\", \"침대\", \"서랍\", \"장롱\", \"테이블\", \"소파\", \"수납\"]):\n",
    "        return \"가구\"\n",
    "    elif any(word in title for word in [\"냉장고\", \"세탁기\", \"전자레인지\", \"청소기\", \"에어컨\", \"선풍기\", \"밥솥\"]):\n",
    "        return \"생활가전\"\n",
    "    elif any(word in title for word in [\"자전거\", \"운동\", \"헬스\", \"축구\", \"농구\", \"라켓\", \"골프\"]):\n",
    "        return \"스포츠\"\n",
    "    elif any(word in title for word in [\"책\", \"교재\", \"전공\", \"토익\", \"문제집\", \"소설\"]):\n",
    "        return \"도서\"\n",
    "    elif any(word in title for word in [\"옷\", \"패딩\", \"자켓\", \"신발\", \"운동화\", \"가방\", \"모자\"]):\n",
    "        return \"패션\"\n",
    "    else:\n",
    "        return \"기타\"\n",
    "\n",
    "df_model[\"카테고리\"] = df_model[\"제목\"].apply(make_category)\n",
    "\n",
    "df_model[[\"제목\", \"카테고리\", \"가격_numeric\"]].head(20)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dd8ab1fb-a5a4-4b8f-ae15-38baf2b8c118",
   "metadata": {},
   "source": [
    "## 19. 상품명 길이 파생 변수 생성\n",
    "\n",
    "상품명에는 브랜드명, 모델명, 상태 정보 등이 포함될 수 있다.\n",
    "\n",
    "상품명이 길수록 상품 정보가 더 구체적으로 작성되었을 가능성이 있으므로,  \n",
    "상품명 길이를 새로운 파생 변수로 생성한다.\n",
    "\n",
    "이 변수는 이후 머신러닝 모델의 입력 변수로 활용할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "d88a8ad9-7d91-4fc4-a081-654cbc3120c9",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>제목길이</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>59</th>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>9</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>60</th>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>61</th>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>63</th>\n",
       "      <td>아동</td>\n",
       "      <td>2</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>64</th>\n",
       "      <td>스피커</td>\n",
       "      <td>3</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            제목  제목길이\n",
       "59   5,000원 이하     9\n",
       "60  10,000원 이하    10\n",
       "61  20,000원 이하    10\n",
       "63          아동     2\n",
       "64         스피커     3"
      ]
     },
     "execution_count": 19,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df_model[\"제목길이\"] = df_model[\"제목\"].astype(str).apply(len)\n",
    "\n",
    "df_model[[\"제목\", \"제목길이\"]].head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "abf196c9-a445-4d50-99cf-5ec3500162a6",
   "metadata": {},
   "source": [
    "## 20. 전처리 완료 데이터 저장\n",
    "\n",
    "전처리가 완료된 데이터를 CSV와 Excel 파일로 저장한다.\n",
    "\n",
    "저장된 데이터는 이후 EDA, 머신러닝 가격 예측 모델, Streamlit 대시보드 제작에 사용된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "3c3945a7-a70c-46a6-a37b-2e4b4a36cd21",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "전처리 완료 데이터 저장 완료\n",
      "CSV 파일: used_items_cleaned.csv\n",
      "Excel 파일: used_items_cleaned.xlsx\n",
      "최종 데이터 크기: (22056, 8)\n"
     ]
    }
   ],
   "source": [
    "df_model.to_csv(\"used_items_cleaned.csv\", index=False, encoding=\"utf-8-sig\")\n",
    "df_model.to_excel(\"used_items_cleaned.xlsx\", index=False)\n",
    "\n",
    "print(\"전처리 완료 데이터 저장 완료\")\n",
    "print(\"CSV 파일: used_items_cleaned.csv\")\n",
    "print(\"Excel 파일: used_items_cleaned.xlsx\")\n",
    "print(\"최종 데이터 크기:\", df_model.shape)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "55c72736-3af1-426a-aba9-279643d7cf2b",
   "metadata": {},
   "source": [
    "# 머신러닝 모델링\n",
    "\n",
    "## 21. 머신러닝 모델의 목표\n",
    "\n",
    "본 프로젝트의 최종 서비스는 사용자가 당근마켓 중고거래 게시글 링크를 입력하면, 해당 게시글에서 상품 제목, 가격, 상세설명을 추출한 뒤 유학생에게 이해하기 쉬운 정보를 제공하는 것이다.\n",
    "\n",
    "전체 서비스 흐름은 다음과 같다.\n",
    "\n",
    "1. 사용자가 중고거래 게시글 링크를 입력한다.\n",
    "2. 시스템이 링크에서 상품 제목, 가격, 상세설명을 추출한다.\n",
    "3. 머신러닝 모델이 상품 제목과 가격 정보를 바탕으로 적정 가격을 예측한다.\n",
    "4. 실제 판매 가격과 예측 적정 가격을 비교하여 가격이 저렴한지, 적정한지, 비싼지 판단한다.\n",
    "5. LLM이 상세설명을 사용자가 원하는 언어로 번역한다.\n",
    "6. 최종 결과를 유학생에게 안내한다.\n",
    "\n",
    "따라서 머신러닝 모델의 핵심 목표는 `중고 상품의 적정 가격 예측`이다."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "38dbd5d4-5e2d-4018-8573-f0bc2c92bfb7",
   "metadata": {},
   "source": [
    "## 22. 전처리 완료 데이터 불러오기\n",
    "\n",
    "앞에서 전처리한 `used_items_cleaned.csv` 파일을 불러온다.\n",
    "\n",
    "이 데이터는 결측치, 중복 데이터, 가격 문자열, 이상치 등이 정리된 상태이며, 머신러닝 모델 학습에 사용할 수 있다.\n",
    "\n",
    "이번 모델에서는 상품명, 카테고리, 상품명 길이 등을 입력 변수로 사용하고, 숫자형 가격인 `가격_numeric`을 예측 대상으로 사용한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "b3f60f80-81a6-442f-b0ac-d49e456ed615",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "데이터 크기: (22056, 8)\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>수집동네</th>\n",
       "      <th>제목</th>\n",
       "      <th>가격</th>\n",
       "      <th>위치/시간</th>\n",
       "      <th>게시글링크</th>\n",
       "      <th>가격_numeric</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>제목길이</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>5,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>5000</td>\n",
       "      <td>기타</td>\n",
       "      <td>9</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>10,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>10000</td>\n",
       "      <td>기타</td>\n",
       "      <td>10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>20,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...</td>\n",
       "      <td>20000</td>\n",
       "      <td>기타</td>\n",
       "      <td>10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>아동</td>\n",
       "      <td>3,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EC%95%84%E...</td>\n",
       "      <td>3000</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>스피커</td>\n",
       "      <td>175,000원</td>\n",
       "      <td>부산광역시 기장군 정관읍</td>\n",
       "      <td>https://www.daangn.com/kr/buy-sell/%EC%8A%A4%E...</td>\n",
       "      <td>175000</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "            수집동네          제목        가격          위치/시간  \\\n",
       "0  부산광역시 기장군 정관읍   5,000원 이하    5,000원  부산광역시 기장군 정관읍   \n",
       "1  부산광역시 기장군 정관읍  10,000원 이하   10,000원  부산광역시 기장군 정관읍   \n",
       "2  부산광역시 기장군 정관읍  20,000원 이하   20,000원  부산광역시 기장군 정관읍   \n",
       "3  부산광역시 기장군 정관읍          아동    3,000원  부산광역시 기장군 정관읍   \n",
       "4  부산광역시 기장군 정관읍         스피커  175,000원  부산광역시 기장군 정관읍   \n",
       "\n",
       "                                               게시글링크  가격_numeric 카테고리  제목길이  \n",
       "0  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...        5000   기타     9  \n",
       "1  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...       10000   기타    10  \n",
       "2  https://www.daangn.com/kr/buy-sell/s/?in=%EC%A...       20000   기타    10  \n",
       "3  https://www.daangn.com/kr/buy-sell/%EC%95%84%E...        3000   기타     2  \n",
       "4  https://www.daangn.com/kr/buy-sell/%EC%8A%A4%E...      175000   기타     3  "
      ]
     },
     "execution_count": 21,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "import re\n",
    "\n",
    "df_model = pd.read_csv(\"used_items_cleaned.csv\")\n",
    "\n",
    "print(\"데이터 크기:\", df_model.shape)\n",
    "df_model.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9b635565-a3ad-4230-b27e-d5f5a03b739a",
   "metadata": {},
   "source": [
    "## 23. 머신러닝 입력 변수와 예측 대상 설정\n",
    "\n",
    "머신러닝 모델은 입력 변수 X를 바탕으로 예측 대상 y를 학습한다.\n",
    "\n",
    "본 프로젝트에서는 다음과 같이 설정한다.\n",
    "\n",
    "- 입력 변수 X: 상품 제목, 카테고리, 제목 길이\n",
    "- 예측 대상 y: 상품 가격\n",
    "\n",
    "현재 수집 데이터에는 상세설명, 조회 수, 관심 수 등이 없기 때문에 우선 상품 제목 기반의 간단한 가격 예측 모델을 만든다.  \n",
    "이후 상세설명이나 관심 수, 채팅 수 등의 데이터가 추가되면 모델 성능을 더 개선할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "baee15fc-cbdb-4715-bc7e-bbe8f8134a2f",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "Index(['수집동네', '제목', '가격', '위치/시간', '게시글링크', '가격_numeric', '카테고리', '제목길이'], dtype='object')"
      ]
     },
     "execution_count": 22,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df_model.columns"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "43589e49-f7a2-414a-ab9e-5a7277831a85",
   "metadata": {},
   "source": [
    "## 24. 머신러닝용 데이터 준비\n",
    "\n",
    "머신러닝 모델 학습을 위해 필요한 컬럼만 선택한다.\n",
    "\n",
    "현재 데이터에서 사용할 주요 변수는 다음과 같다.\n",
    "\n",
    "| 변수명 | 설명 |\n",
    "|---|---|\n",
    "| 제목 | 상품명 |\n",
    "| 카테고리 | 상품명 기반으로 생성한 카테고리 |\n",
    "| 제목길이 | 상품명 글자 수 |\n",
    "| 가격_numeric | 숫자형 상품 가격 |\n",
    "\n",
    "상품명은 문자 데이터이기 때문에 그대로 RandomForest 모델에 넣을 수 없다.  \n",
    "따라서 이후 `TfidfVectorizer`를 활용해 상품명을 숫자 벡터로 변환한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 23,
   "id": "678a8a58-c662-43ad-8106-137c232d24be",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "머신러닝용 데이터 크기: (22056, 4)\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>제목길이</th>\n",
       "      <th>가격_numeric</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>9</td>\n",
       "      <td>5000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>10</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>10</td>\n",
       "      <td>20000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>아동</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>3000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>스피커</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>175000</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "           제목 카테고리  제목길이  가격_numeric\n",
       "0   5,000원 이하   기타     9        5000\n",
       "1  10,000원 이하   기타    10       10000\n",
       "2  20,000원 이하   기타    10       20000\n",
       "3          아동   기타     2        3000\n",
       "4         스피커   기타     3      175000"
      ]
     },
     "execution_count": 23,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# 필요한 컬럼만 선택\n",
    "ml_df = df_model[[\"제목\", \"카테고리\", \"제목길이\", \"가격_numeric\"]].copy()\n",
    "\n",
    "# 결측치 제거\n",
    "ml_df = ml_df.dropna()\n",
    "\n",
    "print(\"머신러닝용 데이터 크기:\", ml_df.shape)\n",
    "ml_df.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c3f8af7d-17fa-4300-8701-33f3dbbfb1d0",
   "metadata": {},
   "source": [
    "## 25. 입력 변수와 타겟 변수 분리\n",
    "\n",
    "가격 예측 모델을 만들기 위해 입력 변수와 예측 대상을 분리한다.\n",
    "\n",
    "- X: 상품 제목, 카테고리, 제목 길이\n",
    "- y: 실제 상품 가격\n",
    "\n",
    "모델은 X에 포함된 정보를 학습하여 y인 가격을 예측하게 된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 24,
   "id": "d2990468-d302-4d58-9254-9014d3569f1b",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>제목길이</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>9</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>아동</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>스피커</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "           제목 카테고리  제목길이\n",
       "0   5,000원 이하   기타     9\n",
       "1  10,000원 이하   기타    10\n",
       "2  20,000원 이하   기타    10\n",
       "3          아동   기타     2\n",
       "4         스피커   기타     3"
      ]
     },
     "execution_count": 24,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "X = ml_df[[\"제목\", \"카테고리\", \"제목길이\"]]\n",
    "y = ml_df[\"가격_numeric\"]\n",
    "\n",
    "X.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "86b52086-d5f3-4af8-9a31-3fe8b0ff9c10",
   "metadata": {},
   "source": [
    "## 26. 학습 데이터와 테스트 데이터 분리\n",
    "\n",
    "모델 성능을 평가하기 위해 데이터를 학습용 데이터와 테스트용 데이터로 나눈다.\n",
    "\n",
    "전체 데이터 중 80%는 모델 학습에 사용하고, 20%는 모델이 처음 보는 데이터에 대해 얼마나 잘 예측하는지 확인하는 테스트 데이터로 사용한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "id": "0feccdc0-b782-4517-9e29-3c4c17ad0213",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "학습 데이터: (17644, 3)\n",
      "테스트 데이터: (4412, 3)\n"
     ]
    }
   ],
   "source": [
    "from sklearn.model_selection import train_test_split\n",
    "\n",
    "X_train, X_test, y_train, y_test = train_test_split(\n",
    "    X,\n",
    "    y,\n",
    "    test_size=0.2,\n",
    "    random_state=42\n",
    ")\n",
    "\n",
    "print(\"학습 데이터:\", X_train.shape)\n",
    "print(\"테스트 데이터:\", X_test.shape)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "63e33218-acad-4054-ab9e-3e66fc5bdc81",
   "metadata": {},
   "source": [
    "## 27. 전처리와 머신러닝 파이프라인 구성\n",
    "\n",
    "머신러닝 모델은 문자 데이터를 그대로 학습할 수 없다.\n",
    "\n",
    "따라서 다음과 같은 전처리 과정을 적용한다.\n",
    "\n",
    "1. `제목`은 TF-IDF 방식으로 숫자 벡터로 변환한다.\n",
    "2. `카테고리`는 One-Hot Encoding으로 변환한다.\n",
    "3. `제목길이`는 숫자형 변수이므로 그대로 사용한다.\n",
    "\n",
    "모델은 `RandomForestRegressor`를 사용한다.  \n",
    "RandomForestRegressor는 여러 개의 의사결정나무를 활용하여 가격과 같은 연속형 값을 예측하는 모델이다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "id": "4919146d-3f81-4d5d-afd6-82fa012615a6",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<style>#sk-container-id-1 {\n",
       "  /* Definition of color scheme common for light and dark mode */\n",
       "  --sklearn-color-text: #000;\n",
       "  --sklearn-color-text-muted: #666;\n",
       "  --sklearn-color-line: gray;\n",
       "  /* Definition of color scheme for unfitted estimators */\n",
       "  --sklearn-color-unfitted-level-0: #fff5e6;\n",
       "  --sklearn-color-unfitted-level-1: #f6e4d2;\n",
       "  --sklearn-color-unfitted-level-2: #ffe0b3;\n",
       "  --sklearn-color-unfitted-level-3: chocolate;\n",
       "  /* Definition of color scheme for fitted estimators */\n",
       "  --sklearn-color-fitted-level-0: #f0f8ff;\n",
       "  --sklearn-color-fitted-level-1: #d4ebff;\n",
       "  --sklearn-color-fitted-level-2: #b3dbfd;\n",
       "  --sklearn-color-fitted-level-3: cornflowerblue;\n",
       "\n",
       "  /* Specific color for light theme */\n",
       "  --sklearn-color-text-on-default-background: var(--sg-text-color, var(--theme-code-foreground, var(--jp-content-font-color1, black)));\n",
       "  --sklearn-color-background: var(--sg-background-color, var(--theme-background, var(--jp-layout-color0, white)));\n",
       "  --sklearn-color-border-box: var(--sg-text-color, var(--theme-code-foreground, var(--jp-content-font-color1, black)));\n",
       "  --sklearn-color-icon: #696969;\n",
       "\n",
       "  @media (prefers-color-scheme: dark) {\n",
       "    /* Redefinition of color scheme for dark theme */\n",
       "    --sklearn-color-text-on-default-background: var(--sg-text-color, var(--theme-code-foreground, var(--jp-content-font-color1, white)));\n",
       "    --sklearn-color-background: var(--sg-background-color, var(--theme-background, var(--jp-layout-color0, #111)));\n",
       "    --sklearn-color-border-box: var(--sg-text-color, var(--theme-code-foreground, var(--jp-content-font-color1, white)));\n",
       "    --sklearn-color-icon: #878787;\n",
       "  }\n",
       "}\n",
       "\n",
       "#sk-container-id-1 {\n",
       "  color: var(--sklearn-color-text);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 pre {\n",
       "  padding: 0;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 input.sk-hidden--visually {\n",
       "  border: 0;\n",
       "  clip: rect(1px 1px 1px 1px);\n",
       "  clip: rect(1px, 1px, 1px, 1px);\n",
       "  height: 1px;\n",
       "  margin: -1px;\n",
       "  overflow: hidden;\n",
       "  padding: 0;\n",
       "  position: absolute;\n",
       "  width: 1px;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-dashed-wrapped {\n",
       "  border: 1px dashed var(--sklearn-color-line);\n",
       "  margin: 0 0.4em 0.5em 0.4em;\n",
       "  box-sizing: border-box;\n",
       "  padding-bottom: 0.4em;\n",
       "  background-color: var(--sklearn-color-background);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-container {\n",
       "  /* jupyter's `normalize.less` sets `[hidden] { display: none; }`\n",
       "     but bootstrap.min.css set `[hidden] { display: none !important; }`\n",
       "     so we also need the `!important` here to be able to override the\n",
       "     default hidden behavior on the sphinx rendered scikit-learn.org.\n",
       "     See: https://github.com/scikit-learn/scikit-learn/issues/21755 */\n",
       "  display: inline-block !important;\n",
       "  position: relative;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-text-repr-fallback {\n",
       "  display: none;\n",
       "}\n",
       "\n",
       "div.sk-parallel-item,\n",
       "div.sk-serial,\n",
       "div.sk-item {\n",
       "  /* draw centered vertical line to link estimators */\n",
       "  background-image: linear-gradient(var(--sklearn-color-text-on-default-background), var(--sklearn-color-text-on-default-background));\n",
       "  background-size: 2px 100%;\n",
       "  background-repeat: no-repeat;\n",
       "  background-position: center center;\n",
       "}\n",
       "\n",
       "/* Parallel-specific style estimator block */\n",
       "\n",
       "#sk-container-id-1 div.sk-parallel-item::after {\n",
       "  content: \"\";\n",
       "  width: 100%;\n",
       "  border-bottom: 2px solid var(--sklearn-color-text-on-default-background);\n",
       "  flex-grow: 1;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-parallel {\n",
       "  display: flex;\n",
       "  align-items: stretch;\n",
       "  justify-content: center;\n",
       "  background-color: var(--sklearn-color-background);\n",
       "  position: relative;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-parallel-item {\n",
       "  display: flex;\n",
       "  flex-direction: column;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-parallel-item:first-child::after {\n",
       "  align-self: flex-end;\n",
       "  width: 50%;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-parallel-item:last-child::after {\n",
       "  align-self: flex-start;\n",
       "  width: 50%;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-parallel-item:only-child::after {\n",
       "  width: 0;\n",
       "}\n",
       "\n",
       "/* Serial-specific style estimator block */\n",
       "\n",
       "#sk-container-id-1 div.sk-serial {\n",
       "  display: flex;\n",
       "  flex-direction: column;\n",
       "  align-items: center;\n",
       "  background-color: var(--sklearn-color-background);\n",
       "  padding-right: 1em;\n",
       "  padding-left: 1em;\n",
       "}\n",
       "\n",
       "\n",
       "/* Toggleable style: style used for estimator/Pipeline/ColumnTransformer box that is\n",
       "clickable and can be expanded/collapsed.\n",
       "- Pipeline and ColumnTransformer use this feature and define the default style\n",
       "- Estimators will overwrite some part of the style using the `sk-estimator` class\n",
       "*/\n",
       "\n",
       "/* Pipeline and ColumnTransformer style (default) */\n",
       "\n",
       "#sk-container-id-1 div.sk-toggleable {\n",
       "  /* Default theme specific background. It is overwritten whether we have a\n",
       "  specific estimator or a Pipeline/ColumnTransformer */\n",
       "  background-color: var(--sklearn-color-background);\n",
       "}\n",
       "\n",
       "/* Toggleable label */\n",
       "#sk-container-id-1 label.sk-toggleable__label {\n",
       "  cursor: pointer;\n",
       "  display: flex;\n",
       "  width: 100%;\n",
       "  margin-bottom: 0;\n",
       "  padding: 0.5em;\n",
       "  box-sizing: border-box;\n",
       "  text-align: center;\n",
       "  align-items: start;\n",
       "  justify-content: space-between;\n",
       "  gap: 0.5em;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 label.sk-toggleable__label .caption {\n",
       "  font-size: 0.6rem;\n",
       "  font-weight: lighter;\n",
       "  color: var(--sklearn-color-text-muted);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 label.sk-toggleable__label-arrow:before {\n",
       "  /* Arrow on the left of the label */\n",
       "  content: \"▸\";\n",
       "  float: left;\n",
       "  margin-right: 0.25em;\n",
       "  color: var(--sklearn-color-icon);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 label.sk-toggleable__label-arrow:hover:before {\n",
       "  color: var(--sklearn-color-text);\n",
       "}\n",
       "\n",
       "/* Toggleable content - dropdown */\n",
       "\n",
       "#sk-container-id-1 div.sk-toggleable__content {\n",
       "  display: none;\n",
       "  text-align: left;\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-0);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-toggleable__content.fitted {\n",
       "  /* fitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-0);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-toggleable__content pre {\n",
       "  margin: 0.2em;\n",
       "  border-radius: 0.25em;\n",
       "  color: var(--sklearn-color-text);\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-0);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-toggleable__content.fitted pre {\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-0);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 input.sk-toggleable__control:checked~div.sk-toggleable__content {\n",
       "  /* Expand drop-down */\n",
       "  display: block;\n",
       "  width: 100%;\n",
       "  overflow: visible;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 input.sk-toggleable__control:checked~label.sk-toggleable__label-arrow:before {\n",
       "  content: \"▾\";\n",
       "}\n",
       "\n",
       "/* Pipeline/ColumnTransformer-specific style */\n",
       "\n",
       "#sk-container-id-1 div.sk-label input.sk-toggleable__control:checked~label.sk-toggleable__label {\n",
       "  color: var(--sklearn-color-text);\n",
       "  background-color: var(--sklearn-color-unfitted-level-2);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-label.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label {\n",
       "  background-color: var(--sklearn-color-fitted-level-2);\n",
       "}\n",
       "\n",
       "/* Estimator-specific style */\n",
       "\n",
       "/* Colorize estimator box */\n",
       "#sk-container-id-1 div.sk-estimator input.sk-toggleable__control:checked~label.sk-toggleable__label {\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-2);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-estimator.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label {\n",
       "  /* fitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-2);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-label label.sk-toggleable__label,\n",
       "#sk-container-id-1 div.sk-label label {\n",
       "  /* The background is the default theme color */\n",
       "  color: var(--sklearn-color-text-on-default-background);\n",
       "}\n",
       "\n",
       "/* On hover, darken the color of the background */\n",
       "#sk-container-id-1 div.sk-label:hover label.sk-toggleable__label {\n",
       "  color: var(--sklearn-color-text);\n",
       "  background-color: var(--sklearn-color-unfitted-level-2);\n",
       "}\n",
       "\n",
       "/* Label box, darken color on hover, fitted */\n",
       "#sk-container-id-1 div.sk-label.fitted:hover label.sk-toggleable__label.fitted {\n",
       "  color: var(--sklearn-color-text);\n",
       "  background-color: var(--sklearn-color-fitted-level-2);\n",
       "}\n",
       "\n",
       "/* Estimator label */\n",
       "\n",
       "#sk-container-id-1 div.sk-label label {\n",
       "  font-family: monospace;\n",
       "  font-weight: bold;\n",
       "  display: inline-block;\n",
       "  line-height: 1.2em;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-label-container {\n",
       "  text-align: center;\n",
       "}\n",
       "\n",
       "/* Estimator-specific */\n",
       "#sk-container-id-1 div.sk-estimator {\n",
       "  font-family: monospace;\n",
       "  border: 1px dotted var(--sklearn-color-border-box);\n",
       "  border-radius: 0.25em;\n",
       "  box-sizing: border-box;\n",
       "  margin-bottom: 0.5em;\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-0);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-estimator.fitted {\n",
       "  /* fitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-0);\n",
       "}\n",
       "\n",
       "/* on hover */\n",
       "#sk-container-id-1 div.sk-estimator:hover {\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-2);\n",
       "}\n",
       "\n",
       "#sk-container-id-1 div.sk-estimator.fitted:hover {\n",
       "  /* fitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-2);\n",
       "}\n",
       "\n",
       "/* Specification for estimator info (e.g. \"i\" and \"?\") */\n",
       "\n",
       "/* Common style for \"i\" and \"?\" */\n",
       "\n",
       ".sk-estimator-doc-link,\n",
       "a:link.sk-estimator-doc-link,\n",
       "a:visited.sk-estimator-doc-link {\n",
       "  float: right;\n",
       "  font-size: smaller;\n",
       "  line-height: 1em;\n",
       "  font-family: monospace;\n",
       "  background-color: var(--sklearn-color-background);\n",
       "  border-radius: 1em;\n",
       "  height: 1em;\n",
       "  width: 1em;\n",
       "  text-decoration: none !important;\n",
       "  margin-left: 0.5em;\n",
       "  text-align: center;\n",
       "  /* unfitted */\n",
       "  border: var(--sklearn-color-unfitted-level-1) 1pt solid;\n",
       "  color: var(--sklearn-color-unfitted-level-1);\n",
       "}\n",
       "\n",
       ".sk-estimator-doc-link.fitted,\n",
       "a:link.sk-estimator-doc-link.fitted,\n",
       "a:visited.sk-estimator-doc-link.fitted {\n",
       "  /* fitted */\n",
       "  border: var(--sklearn-color-fitted-level-1) 1pt solid;\n",
       "  color: var(--sklearn-color-fitted-level-1);\n",
       "}\n",
       "\n",
       "/* On hover */\n",
       "div.sk-estimator:hover .sk-estimator-doc-link:hover,\n",
       ".sk-estimator-doc-link:hover,\n",
       "div.sk-label-container:hover .sk-estimator-doc-link:hover,\n",
       ".sk-estimator-doc-link:hover {\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-3);\n",
       "  color: var(--sklearn-color-background);\n",
       "  text-decoration: none;\n",
       "}\n",
       "\n",
       "div.sk-estimator.fitted:hover .sk-estimator-doc-link.fitted:hover,\n",
       ".sk-estimator-doc-link.fitted:hover,\n",
       "div.sk-label-container:hover .sk-estimator-doc-link.fitted:hover,\n",
       ".sk-estimator-doc-link.fitted:hover {\n",
       "  /* fitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-3);\n",
       "  color: var(--sklearn-color-background);\n",
       "  text-decoration: none;\n",
       "}\n",
       "\n",
       "/* Span, style for the box shown on hovering the info icon */\n",
       ".sk-estimator-doc-link span {\n",
       "  display: none;\n",
       "  z-index: 9999;\n",
       "  position: relative;\n",
       "  font-weight: normal;\n",
       "  right: .2ex;\n",
       "  padding: .5ex;\n",
       "  margin: .5ex;\n",
       "  width: min-content;\n",
       "  min-width: 20ex;\n",
       "  max-width: 50ex;\n",
       "  color: var(--sklearn-color-text);\n",
       "  box-shadow: 2pt 2pt 4pt #999;\n",
       "  /* unfitted */\n",
       "  background: var(--sklearn-color-unfitted-level-0);\n",
       "  border: .5pt solid var(--sklearn-color-unfitted-level-3);\n",
       "}\n",
       "\n",
       ".sk-estimator-doc-link.fitted span {\n",
       "  /* fitted */\n",
       "  background: var(--sklearn-color-fitted-level-0);\n",
       "  border: var(--sklearn-color-fitted-level-3);\n",
       "}\n",
       "\n",
       ".sk-estimator-doc-link:hover span {\n",
       "  display: block;\n",
       "}\n",
       "\n",
       "/* \"?\"-specific style due to the `<a>` HTML tag */\n",
       "\n",
       "#sk-container-id-1 a.estimator_doc_link {\n",
       "  float: right;\n",
       "  font-size: 1rem;\n",
       "  line-height: 1em;\n",
       "  font-family: monospace;\n",
       "  background-color: var(--sklearn-color-background);\n",
       "  border-radius: 1rem;\n",
       "  height: 1rem;\n",
       "  width: 1rem;\n",
       "  text-decoration: none;\n",
       "  /* unfitted */\n",
       "  color: var(--sklearn-color-unfitted-level-1);\n",
       "  border: var(--sklearn-color-unfitted-level-1) 1pt solid;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 a.estimator_doc_link.fitted {\n",
       "  /* fitted */\n",
       "  border: var(--sklearn-color-fitted-level-1) 1pt solid;\n",
       "  color: var(--sklearn-color-fitted-level-1);\n",
       "}\n",
       "\n",
       "/* On hover */\n",
       "#sk-container-id-1 a.estimator_doc_link:hover {\n",
       "  /* unfitted */\n",
       "  background-color: var(--sklearn-color-unfitted-level-3);\n",
       "  color: var(--sklearn-color-background);\n",
       "  text-decoration: none;\n",
       "}\n",
       "\n",
       "#sk-container-id-1 a.estimator_doc_link.fitted:hover {\n",
       "  /* fitted */\n",
       "  background-color: var(--sklearn-color-fitted-level-3);\n",
       "}\n",
       "\n",
       ".estimator-table summary {\n",
       "    padding: .5rem;\n",
       "    font-family: monospace;\n",
       "    cursor: pointer;\n",
       "}\n",
       "\n",
       ".estimator-table details[open] {\n",
       "    padding-left: 0.1rem;\n",
       "    padding-right: 0.1rem;\n",
       "    padding-bottom: 0.3rem;\n",
       "}\n",
       "\n",
       ".estimator-table .parameters-table {\n",
       "    margin-left: auto !important;\n",
       "    margin-right: auto !important;\n",
       "}\n",
       "\n",
       ".estimator-table .parameters-table tr:nth-child(odd) {\n",
       "    background-color: #fff;\n",
       "}\n",
       "\n",
       ".estimator-table .parameters-table tr:nth-child(even) {\n",
       "    background-color: #f6f6f6;\n",
       "}\n",
       "\n",
       ".estimator-table .parameters-table tr:hover {\n",
       "    background-color: #e0e0e0;\n",
       "}\n",
       "\n",
       ".estimator-table table td {\n",
       "    border: 1px solid rgba(106, 105, 104, 0.232);\n",
       "}\n",
       "\n",
       ".user-set td {\n",
       "    color:rgb(255, 94, 0);\n",
       "    text-align: left;\n",
       "}\n",
       "\n",
       ".user-set td.value pre {\n",
       "    color:rgb(255, 94, 0) !important;\n",
       "    background-color: transparent !important;\n",
       "}\n",
       "\n",
       ".default td {\n",
       "    color: black;\n",
       "    text-align: left;\n",
       "}\n",
       "\n",
       ".user-set td i,\n",
       ".default td i {\n",
       "    color: black;\n",
       "}\n",
       "\n",
       ".copy-paste-icon {\n",
       "    background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NDggNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNy4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjUgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTIwOCAwTDMzMi4xIDBjMTIuNyAwIDI0LjkgNS4xIDMzLjkgMTQuMWw2Ny45IDY3LjljOSA5IDE0LjEgMjEuMiAxNC4xIDMzLjlMNDQ4IDMzNmMwIDI2LjUtMjEuNSA0OC00OCA0OGwtMTkyIDBjLTI2LjUgMC00OC0yMS41LTQ4LTQ4bDAtMjg4YzAtMjYuNSAyMS41LTQ4IDQ4LTQ4ek00OCAxMjhsODAgMCAwIDY0LTY0IDAgMCAyNTYgMTkyIDAgMC0zMiA2NCAwIDAgNDhjMCAyNi41LTIxLjUgNDgtNDggNDhMNDggNTEyYy0yNi41IDAtNDgtMjEuNS00OC00OEwwIDE3NmMwLTI2LjUgMjEuNS00OCA0OC00OHoiLz48L3N2Zz4=);\n",
       "    background-repeat: no-repeat;\n",
       "    background-size: 14px 14px;\n",
       "    background-position: 0;\n",
       "    display: inline-block;\n",
       "    width: 14px;\n",
       "    height: 14px;\n",
       "    cursor: pointer;\n",
       "}\n",
       "</style><body><div id=\"sk-container-id-1\" class=\"sk-top-container\"><div class=\"sk-text-repr-fallback\"><pre>Pipeline(steps=[(&#x27;preprocessor&#x27;,\n",
       "                 ColumnTransformer(transformers=[(&#x27;title_tfidf&#x27;,\n",
       "                                                  TfidfVectorizer(max_features=300),\n",
       "                                                  &#x27;제목&#x27;),\n",
       "                                                 (&#x27;category_ohe&#x27;,\n",
       "                                                  OneHotEncoder(handle_unknown=&#x27;ignore&#x27;),\n",
       "                                                  [&#x27;카테고리&#x27;]),\n",
       "                                                 (&#x27;num&#x27;, &#x27;passthrough&#x27;,\n",
       "                                                  [&#x27;제목길이&#x27;])])),\n",
       "                (&#x27;regressor&#x27;,\n",
       "                 RandomForestRegressor(n_estimators=200, random_state=42))])</pre><b>In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. <br />On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.</b></div><div class=\"sk-container\" hidden><div class=\"sk-item sk-dashed-wrapped\"><div class=\"sk-label-container\"><div class=\"sk-label  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-1\" type=\"checkbox\" ><label for=\"sk-estimator-id-1\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>Pipeline</div></div><div><a class=\"sk-estimator-doc-link \" rel=\"noreferrer\" target=\"_blank\" href=\"https://scikit-learn.org/1.7/modules/generated/sklearn.pipeline.Pipeline.html\">?<span>Documentation for Pipeline</span></a><span class=\"sk-estimator-doc-link \">i<span>Not fitted</span></span></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"\">\n",
       "        <div class=\"estimator-table\">\n",
       "            <details>\n",
       "                <summary>Parameters</summary>\n",
       "                <table class=\"parameters-table\">\n",
       "                  <tbody>\n",
       "                    \n",
       "        <tr class=\"user-set\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('steps',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">steps&nbsp;</td>\n",
       "            <td class=\"value\">[(&#x27;preprocessor&#x27;, ...), (&#x27;regressor&#x27;, ...)]</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('transform_input',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">transform_input&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('memory',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">memory&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('verbose',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">verbose&nbsp;</td>\n",
       "            <td class=\"value\">False</td>\n",
       "        </tr>\n",
       "    \n",
       "                  </tbody>\n",
       "                </table>\n",
       "            </details>\n",
       "        </div>\n",
       "    </div></div></div><div class=\"sk-serial\"><div class=\"sk-item sk-dashed-wrapped\"><div class=\"sk-label-container\"><div class=\"sk-label  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-2\" type=\"checkbox\" ><label for=\"sk-estimator-id-2\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>preprocessor: ColumnTransformer</div></div><div><a class=\"sk-estimator-doc-link \" rel=\"noreferrer\" target=\"_blank\" href=\"https://scikit-learn.org/1.7/modules/generated/sklearn.compose.ColumnTransformer.html\">?<span>Documentation for preprocessor: ColumnTransformer</span></a></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__\">\n",
       "        <div class=\"estimator-table\">\n",
       "            <details>\n",
       "                <summary>Parameters</summary>\n",
       "                <table class=\"parameters-table\">\n",
       "                  <tbody>\n",
       "                    \n",
       "        <tr class=\"user-set\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('transformers',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">transformers&nbsp;</td>\n",
       "            <td class=\"value\">[(&#x27;title_tfidf&#x27;, ...), (&#x27;category_ohe&#x27;, ...), ...]</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('remainder',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">remainder&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;drop&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('sparse_threshold',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">sparse_threshold&nbsp;</td>\n",
       "            <td class=\"value\">0.3</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('n_jobs',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">n_jobs&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('transformer_weights',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">transformer_weights&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('verbose',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">verbose&nbsp;</td>\n",
       "            <td class=\"value\">False</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('verbose_feature_names_out',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">verbose_feature_names_out&nbsp;</td>\n",
       "            <td class=\"value\">True</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('force_int_remainder_cols',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">force_int_remainder_cols&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;deprecated&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "                  </tbody>\n",
       "                </table>\n",
       "            </details>\n",
       "        </div>\n",
       "    </div></div></div><div class=\"sk-parallel\"><div class=\"sk-parallel-item\"><div class=\"sk-item\"><div class=\"sk-label-container\"><div class=\"sk-label  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-3\" type=\"checkbox\" ><label for=\"sk-estimator-id-3\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>title_tfidf</div></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__title_tfidf__\"><pre>제목</pre></div></div></div><div class=\"sk-serial\"><div class=\"sk-item\"><div class=\"sk-estimator  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-4\" type=\"checkbox\" ><label for=\"sk-estimator-id-4\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>TfidfVectorizer</div></div><div><a class=\"sk-estimator-doc-link \" rel=\"noreferrer\" target=\"_blank\" href=\"https://scikit-learn.org/1.7/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html\">?<span>Documentation for TfidfVectorizer</span></a></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__title_tfidf__\">\n",
       "        <div class=\"estimator-table\">\n",
       "            <details>\n",
       "                <summary>Parameters</summary>\n",
       "                <table class=\"parameters-table\">\n",
       "                  <tbody>\n",
       "                    \n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('input',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">input&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;content&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('encoding',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">encoding&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;utf-8&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('decode_error',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">decode_error&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;strict&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('strip_accents',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">strip_accents&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('lowercase',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">lowercase&nbsp;</td>\n",
       "            <td class=\"value\">True</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('preprocessor',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">preprocessor&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('tokenizer',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">tokenizer&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('analyzer',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">analyzer&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;word&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('stop_words',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">stop_words&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('token_pattern',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">token_pattern&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;(?u)\\\\b\\\\w\\\\w+\\\\b&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('ngram_range',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">ngram_range&nbsp;</td>\n",
       "            <td class=\"value\">(1, ...)</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_df',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_df&nbsp;</td>\n",
       "            <td class=\"value\">1.0</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('min_df',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">min_df&nbsp;</td>\n",
       "            <td class=\"value\">1</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"user-set\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_features',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_features&nbsp;</td>\n",
       "            <td class=\"value\">300</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('vocabulary',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">vocabulary&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('binary',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">binary&nbsp;</td>\n",
       "            <td class=\"value\">False</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('dtype',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">dtype&nbsp;</td>\n",
       "            <td class=\"value\">&lt;class &#x27;numpy.float64&#x27;&gt;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('norm',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">norm&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;l2&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('use_idf',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">use_idf&nbsp;</td>\n",
       "            <td class=\"value\">True</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('smooth_idf',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">smooth_idf&nbsp;</td>\n",
       "            <td class=\"value\">True</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('sublinear_tf',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">sublinear_tf&nbsp;</td>\n",
       "            <td class=\"value\">False</td>\n",
       "        </tr>\n",
       "    \n",
       "                  </tbody>\n",
       "                </table>\n",
       "            </details>\n",
       "        </div>\n",
       "    </div></div></div></div></div></div><div class=\"sk-parallel-item\"><div class=\"sk-item\"><div class=\"sk-label-container\"><div class=\"sk-label  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-5\" type=\"checkbox\" ><label for=\"sk-estimator-id-5\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>category_ohe</div></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__category_ohe__\"><pre>[&#x27;카테고리&#x27;]</pre></div></div></div><div class=\"sk-serial\"><div class=\"sk-item\"><div class=\"sk-estimator  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-6\" type=\"checkbox\" ><label for=\"sk-estimator-id-6\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>OneHotEncoder</div></div><div><a class=\"sk-estimator-doc-link \" rel=\"noreferrer\" target=\"_blank\" href=\"https://scikit-learn.org/1.7/modules/generated/sklearn.preprocessing.OneHotEncoder.html\">?<span>Documentation for OneHotEncoder</span></a></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__category_ohe__\">\n",
       "        <div class=\"estimator-table\">\n",
       "            <details>\n",
       "                <summary>Parameters</summary>\n",
       "                <table class=\"parameters-table\">\n",
       "                  <tbody>\n",
       "                    \n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('categories',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">categories&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;auto&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('drop',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">drop&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('sparse_output',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">sparse_output&nbsp;</td>\n",
       "            <td class=\"value\">True</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('dtype',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">dtype&nbsp;</td>\n",
       "            <td class=\"value\">&lt;class &#x27;numpy.float64&#x27;&gt;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"user-set\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('handle_unknown',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">handle_unknown&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;ignore&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('min_frequency',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">min_frequency&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_categories',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_categories&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('feature_name_combiner',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">feature_name_combiner&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;concat&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "                  </tbody>\n",
       "                </table>\n",
       "            </details>\n",
       "        </div>\n",
       "    </div></div></div></div></div></div><div class=\"sk-parallel-item\"><div class=\"sk-item\"><div class=\"sk-label-container\"><div class=\"sk-label  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-7\" type=\"checkbox\" ><label for=\"sk-estimator-id-7\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>num</div></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__num__\"><pre>[&#x27;제목길이&#x27;]</pre></div></div></div><div class=\"sk-serial\"><div class=\"sk-item\"><div class=\"sk-estimator  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-8\" type=\"checkbox\" ><label for=\"sk-estimator-id-8\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>passthrough</div></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"preprocessor__num__\"><pre>passthrough</pre></div></div></div></div></div></div></div></div><div class=\"sk-item\"><div class=\"sk-estimator  sk-toggleable\"><input class=\"sk-toggleable__control sk-hidden--visually\" id=\"sk-estimator-id-9\" type=\"checkbox\" ><label for=\"sk-estimator-id-9\" class=\"sk-toggleable__label  sk-toggleable__label-arrow\"><div><div>RandomForestRegressor</div></div><div><a class=\"sk-estimator-doc-link \" rel=\"noreferrer\" target=\"_blank\" href=\"https://scikit-learn.org/1.7/modules/generated/sklearn.ensemble.RandomForestRegressor.html\">?<span>Documentation for RandomForestRegressor</span></a></div></label><div class=\"sk-toggleable__content \" data-param-prefix=\"regressor__\">\n",
       "        <div class=\"estimator-table\">\n",
       "            <details>\n",
       "                <summary>Parameters</summary>\n",
       "                <table class=\"parameters-table\">\n",
       "                  <tbody>\n",
       "                    \n",
       "        <tr class=\"user-set\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('n_estimators',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">n_estimators&nbsp;</td>\n",
       "            <td class=\"value\">200</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('criterion',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">criterion&nbsp;</td>\n",
       "            <td class=\"value\">&#x27;squared_error&#x27;</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_depth',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_depth&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('min_samples_split',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">min_samples_split&nbsp;</td>\n",
       "            <td class=\"value\">2</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('min_samples_leaf',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">min_samples_leaf&nbsp;</td>\n",
       "            <td class=\"value\">1</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('min_weight_fraction_leaf',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">min_weight_fraction_leaf&nbsp;</td>\n",
       "            <td class=\"value\">0.0</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_features',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_features&nbsp;</td>\n",
       "            <td class=\"value\">1.0</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_leaf_nodes',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_leaf_nodes&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('min_impurity_decrease',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">min_impurity_decrease&nbsp;</td>\n",
       "            <td class=\"value\">0.0</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('bootstrap',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">bootstrap&nbsp;</td>\n",
       "            <td class=\"value\">True</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('oob_score',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">oob_score&nbsp;</td>\n",
       "            <td class=\"value\">False</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('n_jobs',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">n_jobs&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"user-set\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('random_state',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">random_state&nbsp;</td>\n",
       "            <td class=\"value\">42</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('verbose',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">verbose&nbsp;</td>\n",
       "            <td class=\"value\">0</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('warm_start',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">warm_start&nbsp;</td>\n",
       "            <td class=\"value\">False</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('ccp_alpha',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">ccp_alpha&nbsp;</td>\n",
       "            <td class=\"value\">0.0</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('max_samples',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">max_samples&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "\n",
       "        <tr class=\"default\">\n",
       "            <td><i class=\"copy-paste-icon\"\n",
       "                 onclick=\"copyToClipboard('monotonic_cst',\n",
       "                          this.parentElement.nextElementSibling)\"\n",
       "            ></i></td>\n",
       "            <td class=\"param\">monotonic_cst&nbsp;</td>\n",
       "            <td class=\"value\">None</td>\n",
       "        </tr>\n",
       "    \n",
       "                  </tbody>\n",
       "                </table>\n",
       "            </details>\n",
       "        </div>\n",
       "    </div></div></div></div></div></div></div><script>function copyToClipboard(text, element) {\n",
       "    // Get the parameter prefix from the closest toggleable content\n",
       "    const toggleableContent = element.closest('.sk-toggleable__content');\n",
       "    const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : '';\n",
       "    const fullParamName = paramPrefix ? `${paramPrefix}${text}` : text;\n",
       "\n",
       "    const originalStyle = element.style;\n",
       "    const computedStyle = window.getComputedStyle(element);\n",
       "    const originalWidth = computedStyle.width;\n",
       "    const originalHTML = element.innerHTML.replace('Copied!', '');\n",
       "\n",
       "    navigator.clipboard.writeText(fullParamName)\n",
       "        .then(() => {\n",
       "            element.style.width = originalWidth;\n",
       "            element.style.color = 'green';\n",
       "            element.innerHTML = \"Copied!\";\n",
       "\n",
       "            setTimeout(() => {\n",
       "                element.innerHTML = originalHTML;\n",
       "                element.style = originalStyle;\n",
       "            }, 2000);\n",
       "        })\n",
       "        .catch(err => {\n",
       "            console.error('Failed to copy:', err);\n",
       "            element.style.color = 'red';\n",
       "            element.innerHTML = \"Failed!\";\n",
       "            setTimeout(() => {\n",
       "                element.innerHTML = originalHTML;\n",
       "                element.style = originalStyle;\n",
       "            }, 2000);\n",
       "        });\n",
       "    return false;\n",
       "}\n",
       "\n",
       "document.querySelectorAll('.fa-regular.fa-copy').forEach(function(element) {\n",
       "    const toggleableContent = element.closest('.sk-toggleable__content');\n",
       "    const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : '';\n",
       "    const paramName = element.parentElement.nextElementSibling.textContent.trim();\n",
       "    const fullParamName = paramPrefix ? `${paramPrefix}${paramName}` : paramName;\n",
       "\n",
       "    element.setAttribute('title', fullParamName);\n",
       "});\n",
       "</script></body>"
      ],
      "text/plain": [
       "Pipeline(steps=[('preprocessor',\n",
       "                 ColumnTransformer(transformers=[('title_tfidf',\n",
       "                                                  TfidfVectorizer(max_features=300),\n",
       "                                                  '제목'),\n",
       "                                                 ('category_ohe',\n",
       "                                                  OneHotEncoder(handle_unknown='ignore'),\n",
       "                                                  ['카테고리']),\n",
       "                                                 ('num', 'passthrough',\n",
       "                                                  ['제목길이'])])),\n",
       "                ('regressor',\n",
       "                 RandomForestRegressor(n_estimators=200, random_state=42))])"
      ]
     },
     "execution_count": 26,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import OneHotEncoder\n",
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "from sklearn.ensemble import RandomForestRegressor\n",
    "\n",
    "preprocessor = ColumnTransformer(\n",
    "    transformers=[\n",
    "        (\"title_tfidf\", TfidfVectorizer(max_features=300), \"제목\"),\n",
    "        (\"category_ohe\", OneHotEncoder(handle_unknown=\"ignore\"), [\"카테고리\"]),\n",
    "        (\"num\", \"passthrough\", [\"제목길이\"])\n",
    "    ]\n",
    ")\n",
    "\n",
    "price_model = Pipeline(\n",
    "    steps=[\n",
    "        (\"preprocessor\", preprocessor),\n",
    "        (\"regressor\", RandomForestRegressor(\n",
    "            n_estimators=200,\n",
    "            random_state=42\n",
    "        ))\n",
    "    ]\n",
    ")\n",
    "\n",
    "price_model"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "313a6575-aeeb-4e3d-bb9e-e57b02f17f95",
   "metadata": {},
   "source": [
    "## 28. 가격 예측 모델 학습\n",
    "\n",
    "학습 데이터를 사용하여 중고 상품 가격 예측 모델을 학습한다.\n",
    "\n",
    "모델은 상품 제목에 포함된 단어, 카테고리, 제목 길이 정보를 바탕으로 중고 상품의 가격 패턴을 학습한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "id": "6b59d6fa-7176-4b28-86b3-a75b49c4b467",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "가격 예측 모델 학습 완료\n"
     ]
    }
   ],
   "source": [
    "price_model.fit(X_train, y_train)\n",
    "\n",
    "print(\"가격 예측 모델 학습 완료\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5d32a4df-73a4-4b8f-acc3-de58b16573aa",
   "metadata": {},
   "source": [
    "## 29. 모델 예측 및 성능 평가\n",
    "\n",
    "학습된 모델이 테스트 데이터에서 가격을 얼마나 잘 예측하는지 확인한다.\n",
    "\n",
    "평가 지표는 다음과 같다.\n",
    "\n",
    "| 지표 | 설명 |\n",
    "|---|---|\n",
    "| MAE | 실제 가격과 예측 가격의 평균 절대 오차 |\n",
    "| RMSE | 큰 오차에 더 민감한 평균 제곱근 오차 |\n",
    "| R2 Score | 모델이 가격 변동을 얼마나 설명하는지 나타내는 지표 |\n",
    "\n",
    "중고거래 가격 예측 서비스에서는 사용자가 실제 가격 차이를 쉽게 이해할 수 있어야 하므로 MAE가 중요하다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "id": "535088e5-8a6b-4d29-a2b9-60a7a0547efa",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "MAE: 98166.78\n",
      "RMSE: 202848.32\n",
      "R2 Score: 0.074\n"
     ]
    }
   ],
   "source": [
    "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n",
    "\n",
    "y_pred = price_model.predict(X_test)\n",
    "\n",
    "mae = mean_absolute_error(y_test, y_pred)\n",
    "rmse = np.sqrt(mean_squared_error(y_test, y_pred))\n",
    "r2 = r2_score(y_test, y_pred)\n",
    "\n",
    "print(\"MAE:\", round(mae, 2))\n",
    "print(\"RMSE:\", round(rmse, 2))\n",
    "print(\"R2 Score:\", round(r2, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1ae8a135-3499-4a58-ab25-8d0b41f3e201",
   "metadata": {},
   "source": [
    "## 30. 실제 가격과 예측 가격 비교\n",
    "\n",
    "테스트 데이터에 대해 실제 가격과 모델이 예측한 가격을 비교한다.\n",
    "\n",
    "이 결과를 통해 모델이 어느 상품에서 가격을 잘 맞추고, 어느 상품에서 오차가 큰지 확인할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "id": "3e427f3a-dc11-42b2-8048-28930be2688f",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>제목길이</th>\n",
       "      <th>실제가격</th>\n",
       "      <th>예측가격</th>\n",
       "      <th>오차</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>9041</th>\n",
       "      <td>키플링</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>15000</td>\n",
       "      <td>79383</td>\n",
       "      <td>64383</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10008</th>\n",
       "      <td>VONVIK</td>\n",
       "      <td>기타</td>\n",
       "      <td>6</td>\n",
       "      <td>13000</td>\n",
       "      <td>86347</td>\n",
       "      <td>73347</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5865</th>\n",
       "      <td>모던한</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>50000</td>\n",
       "      <td>22889</td>\n",
       "      <td>27111</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10327</th>\n",
       "      <td>산골이유식</td>\n",
       "      <td>기타</td>\n",
       "      <td>5</td>\n",
       "      <td>25000</td>\n",
       "      <td>79339</td>\n",
       "      <td>54339</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13614</th>\n",
       "      <td>공룡</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>8000</td>\n",
       "      <td>80844</td>\n",
       "      <td>72844</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>19477</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>8000</td>\n",
       "      <td>90994</td>\n",
       "      <td>82994</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7497</th>\n",
       "      <td>이케아</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>70000</td>\n",
       "      <td>39402</td>\n",
       "      <td>30598</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6480</th>\n",
       "      <td>테일러메이드</td>\n",
       "      <td>기타</td>\n",
       "      <td>6</td>\n",
       "      <td>50000</td>\n",
       "      <td>146725</td>\n",
       "      <td>96725</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>812</th>\n",
       "      <td>2단</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>5000</td>\n",
       "      <td>80844</td>\n",
       "      <td>75844</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>21736</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>10000</td>\n",
       "      <td>90994</td>\n",
       "      <td>80994</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>15828</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>450000</td>\n",
       "      <td>90994</td>\n",
       "      <td>359006</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>19720</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>19000</td>\n",
       "      <td>90994</td>\n",
       "      <td>71994</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9953</th>\n",
       "      <td>[새제품]</td>\n",
       "      <td>기타</td>\n",
       "      <td>5</td>\n",
       "      <td>110000</td>\n",
       "      <td>105116</td>\n",
       "      <td>4884</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1201</th>\n",
       "      <td>ROYAL</td>\n",
       "      <td>기타</td>\n",
       "      <td>5</td>\n",
       "      <td>10000</td>\n",
       "      <td>79339</td>\n",
       "      <td>69339</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13759</th>\n",
       "      <td>s925</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>35000</td>\n",
       "      <td>34225</td>\n",
       "      <td>775</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7889</th>\n",
       "      <td>Sherwood</td>\n",
       "      <td>기타</td>\n",
       "      <td>8</td>\n",
       "      <td>120000</td>\n",
       "      <td>102506</td>\n",
       "      <td>17494</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8214</th>\n",
       "      <td>미감수</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>4000</td>\n",
       "      <td>79383</td>\n",
       "      <td>75383</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2350</th>\n",
       "      <td>이블렛,공구우먼</td>\n",
       "      <td>기타</td>\n",
       "      <td>8</td>\n",
       "      <td>6000</td>\n",
       "      <td>102506</td>\n",
       "      <td>96506</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>20801</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>5000</td>\n",
       "      <td>90994</td>\n",
       "      <td>85994</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3946</th>\n",
       "      <td>벤츠</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>200000</td>\n",
       "      <td>214734</td>\n",
       "      <td>14734</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             제목 카테고리  제목길이    실제가격    예측가격      오차\n",
       "9041        키플링   기타     3   15000   79383   64383\n",
       "10008    VONVIK   기타     6   13000   86347   73347\n",
       "5865        모던한   기타     3   50000   22889   27111\n",
       "10327     산골이유식   기타     5   25000   79339   54339\n",
       "13614        공룡   기타     2    8000   80844   72844\n",
       "19477      거래완료   기타     4    8000   90994   82994\n",
       "7497        이케아   기타     3   70000   39402   30598\n",
       "6480     테일러메이드   기타     6   50000  146725   96725\n",
       "812          2단   기타     2    5000   80844   75844\n",
       "21736      거래완료   기타     4   10000   90994   80994\n",
       "15828      거래완료   기타     4  450000   90994  359006\n",
       "19720      거래완료   기타     4   19000   90994   71994\n",
       "9953      [새제품]   기타     5  110000  105116    4884\n",
       "1201      ROYAL   기타     5   10000   79339   69339\n",
       "13759      s925   기타     4   35000   34225     775\n",
       "7889   Sherwood   기타     8  120000  102506   17494\n",
       "8214        미감수   기타     3    4000   79383   75383\n",
       "2350   이블렛,공구우먼   기타     8    6000  102506   96506\n",
       "20801      거래완료   기타     4    5000   90994   85994\n",
       "3946         벤츠   기타     2  200000  214734   14734"
      ]
     },
     "execution_count": 29,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "result_df = X_test.copy()\n",
    "result_df[\"실제가격\"] = y_test.values\n",
    "result_df[\"예측가격\"] = y_pred.astype(int)\n",
    "result_df[\"오차\"] = abs(result_df[\"실제가격\"] - result_df[\"예측가격\"])\n",
    "\n",
    "result_df.head(20)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "693435b1-396e-4889-87a3-bd12bdbde5d4",
   "metadata": {},
   "source": [
    "## 31. 가격 적정성 판단 함수 생성\n",
    "\n",
    "최종 서비스에서는 단순히 예측 가격만 보여주는 것이 아니라, 사용자가 입력한 상품 가격이 적절한지 판단해야 한다.\n",
    "\n",
    "따라서 실제 판매 가격과 모델이 예측한 적정 가격을 비교하여 다음과 같이 분류한다.\n",
    "\n",
    "- 실제 가격이 예측 가격보다 20% 이상 낮으면 `저렴`\n",
    "- 실제 가격이 예측 가격과 비슷하면 `적정`\n",
    "- 실제 가격이 예측 가격보다 20% 이상 높으면 `비쌈`\n",
    "\n",
    "이 판단 결과는 유학생이 중고거래 상품을 구매할 때 가격이 합리적인지 이해하는 데 도움을 줄 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "id": "735826fc-1838-4b3f-b182-92acf448c1f7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def judge_price(actual_price, predicted_price):\n",
    "    if actual_price < predicted_price * 0.8:\n",
    "        return \"저렴\"\n",
    "    elif actual_price > predicted_price * 1.2:\n",
    "        return \"비쌈\"\n",
    "    else:\n",
    "        return \"적정\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "100ba0d7-9ada-4e1c-b13e-a0629a0a02f5",
   "metadata": {},
   "source": [
    "## 32. 가격 적정성 판단 결과 확인\n",
    "\n",
    "테스트 데이터에 대해 가격 적정성 판단 함수를 적용한다.\n",
    "\n",
    "이를 통해 각 상품의 실제 판매 가격이 모델이 예측한 적정 가격보다 저렴한지, 적정한지, 비싼지 확인할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "id": "91ef6463-5a5f-4861-ad3a-3e52b213ae45",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>제목길이</th>\n",
       "      <th>실제가격</th>\n",
       "      <th>예측가격</th>\n",
       "      <th>오차</th>\n",
       "      <th>가격판단</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>9041</th>\n",
       "      <td>키플링</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>15000</td>\n",
       "      <td>79383</td>\n",
       "      <td>64383</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10008</th>\n",
       "      <td>VONVIK</td>\n",
       "      <td>기타</td>\n",
       "      <td>6</td>\n",
       "      <td>13000</td>\n",
       "      <td>86347</td>\n",
       "      <td>73347</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5865</th>\n",
       "      <td>모던한</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>50000</td>\n",
       "      <td>22889</td>\n",
       "      <td>27111</td>\n",
       "      <td>비쌈</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10327</th>\n",
       "      <td>산골이유식</td>\n",
       "      <td>기타</td>\n",
       "      <td>5</td>\n",
       "      <td>25000</td>\n",
       "      <td>79339</td>\n",
       "      <td>54339</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13614</th>\n",
       "      <td>공룡</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>8000</td>\n",
       "      <td>80844</td>\n",
       "      <td>72844</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>19477</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>8000</td>\n",
       "      <td>90994</td>\n",
       "      <td>82994</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7497</th>\n",
       "      <td>이케아</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>70000</td>\n",
       "      <td>39402</td>\n",
       "      <td>30598</td>\n",
       "      <td>비쌈</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6480</th>\n",
       "      <td>테일러메이드</td>\n",
       "      <td>기타</td>\n",
       "      <td>6</td>\n",
       "      <td>50000</td>\n",
       "      <td>146725</td>\n",
       "      <td>96725</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>812</th>\n",
       "      <td>2단</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>5000</td>\n",
       "      <td>80844</td>\n",
       "      <td>75844</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>21736</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>10000</td>\n",
       "      <td>90994</td>\n",
       "      <td>80994</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>15828</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>450000</td>\n",
       "      <td>90994</td>\n",
       "      <td>359006</td>\n",
       "      <td>비쌈</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>19720</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>19000</td>\n",
       "      <td>90994</td>\n",
       "      <td>71994</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9953</th>\n",
       "      <td>[새제품]</td>\n",
       "      <td>기타</td>\n",
       "      <td>5</td>\n",
       "      <td>110000</td>\n",
       "      <td>105116</td>\n",
       "      <td>4884</td>\n",
       "      <td>적정</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1201</th>\n",
       "      <td>ROYAL</td>\n",
       "      <td>기타</td>\n",
       "      <td>5</td>\n",
       "      <td>10000</td>\n",
       "      <td>79339</td>\n",
       "      <td>69339</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13759</th>\n",
       "      <td>s925</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>35000</td>\n",
       "      <td>34225</td>\n",
       "      <td>775</td>\n",
       "      <td>적정</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7889</th>\n",
       "      <td>Sherwood</td>\n",
       "      <td>기타</td>\n",
       "      <td>8</td>\n",
       "      <td>120000</td>\n",
       "      <td>102506</td>\n",
       "      <td>17494</td>\n",
       "      <td>적정</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8214</th>\n",
       "      <td>미감수</td>\n",
       "      <td>기타</td>\n",
       "      <td>3</td>\n",
       "      <td>4000</td>\n",
       "      <td>79383</td>\n",
       "      <td>75383</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2350</th>\n",
       "      <td>이블렛,공구우먼</td>\n",
       "      <td>기타</td>\n",
       "      <td>8</td>\n",
       "      <td>6000</td>\n",
       "      <td>102506</td>\n",
       "      <td>96506</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>20801</th>\n",
       "      <td>거래완료</td>\n",
       "      <td>기타</td>\n",
       "      <td>4</td>\n",
       "      <td>5000</td>\n",
       "      <td>90994</td>\n",
       "      <td>85994</td>\n",
       "      <td>저렴</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3946</th>\n",
       "      <td>벤츠</td>\n",
       "      <td>기타</td>\n",
       "      <td>2</td>\n",
       "      <td>200000</td>\n",
       "      <td>214734</td>\n",
       "      <td>14734</td>\n",
       "      <td>적정</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             제목 카테고리  제목길이    실제가격    예측가격      오차 가격판단\n",
       "9041        키플링   기타     3   15000   79383   64383   저렴\n",
       "10008    VONVIK   기타     6   13000   86347   73347   저렴\n",
       "5865        모던한   기타     3   50000   22889   27111   비쌈\n",
       "10327     산골이유식   기타     5   25000   79339   54339   저렴\n",
       "13614        공룡   기타     2    8000   80844   72844   저렴\n",
       "19477      거래완료   기타     4    8000   90994   82994   저렴\n",
       "7497        이케아   기타     3   70000   39402   30598   비쌈\n",
       "6480     테일러메이드   기타     6   50000  146725   96725   저렴\n",
       "812          2단   기타     2    5000   80844   75844   저렴\n",
       "21736      거래완료   기타     4   10000   90994   80994   저렴\n",
       "15828      거래완료   기타     4  450000   90994  359006   비쌈\n",
       "19720      거래완료   기타     4   19000   90994   71994   저렴\n",
       "9953      [새제품]   기타     5  110000  105116    4884   적정\n",
       "1201      ROYAL   기타     5   10000   79339   69339   저렴\n",
       "13759      s925   기타     4   35000   34225     775   적정\n",
       "7889   Sherwood   기타     8  120000  102506   17494   적정\n",
       "8214        미감수   기타     3    4000   79383   75383   저렴\n",
       "2350   이블렛,공구우먼   기타     8    6000  102506   96506   저렴\n",
       "20801      거래완료   기타     4    5000   90994   85994   저렴\n",
       "3946         벤츠   기타     2  200000  214734   14734   적정"
      ]
     },
     "execution_count": 32,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "result_df[\"가격판단\"] = result_df.apply(\n",
    "    lambda row: judge_price(row[\"실제가격\"], row[\"예측가격\"]),\n",
    "    axis=1\n",
    ")\n",
    "\n",
    "result_df.head(20)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f06cfd48-6c95-4e44-b401-8aae566ddbba",
   "metadata": {},
   "source": [
    "## 33. 새 상품 가격 예측 테스트\n",
    "\n",
    "최종 앱에서는 사용자가 중고거래 링크를 입력하면 해당 게시글에서 상품 제목과 가격을 추출한다.\n",
    "\n",
    "이 단계에서는 링크에서 추출되었다고 가정한 샘플 상품 정보를 입력하여 모델이 적정 가격을 예측하고 가격 판단까지 수행하는지 확인한다.## 33. 새 상품 가격 예측 테스트\n",
    "\n",
    "최종 앱에서는 사용자가 중고거래 링크를 입력하면 해당 게시글에서 상품 제목과 가격을 추출한다.\n",
    "\n",
    "이 단계에서는 링크에서 추출되었다고 가정한 샘플 상품 정보를 입력하여 모델이 적정 가격을 예측하고 가격 판단까지 수행하는지 확인한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 33,
   "id": "f96b0e8c-ec43-4d52-b151-ff3f0a6c2b5e",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "상품명: 아이폰 13 128기가 판매합니다\n",
      "판매 가격: 450,000원\n",
      "예측 적정 가격: 344,835원\n",
      "가격 판단: 비쌈\n"
     ]
    }
   ],
   "source": [
    "sample_title = \"아이폰 13 128기가 판매합니다\"\n",
    "sample_category = \"전자기기\"\n",
    "sample_price = 450000\n",
    "\n",
    "sample_data = pd.DataFrame([{\n",
    "    \"제목\": sample_title,\n",
    "    \"카테고리\": sample_category,\n",
    "    \"제목길이\": len(sample_title)\n",
    "}])\n",
    "\n",
    "predicted_price = price_model.predict(sample_data)[0]\n",
    "price_status = judge_price(sample_price, predicted_price)\n",
    "\n",
    "print(\"상품명:\", sample_title)\n",
    "print(\"판매 가격:\", f\"{sample_price:,}원\")\n",
    "print(\"예측 적정 가격:\", f\"{int(predicted_price):,}원\")\n",
    "print(\"가격 판단:\", price_status)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aeeb726f-553c-465d-b2f7-5e0ae49e39f7",
   "metadata": {},
   "source": [
    "sample_title = \"아이폰 13 128기가 판매합니다\"\n",
    "sample_category = \"전자기기\"\n",
    "sample_price = 450000\n",
    "\n",
    "sample_data = pd.DataFrame([{\n",
    "    \"제목\": sample_title,\n",
    "    \"카테고리\": sample_category,\n",
    "    \"제목길이\": len(sample_title)\n",
    "}])\n",
    "\n",
    "predicted_price = price_model.predict(sample_data)[0]\n",
    "price_status = judge_price(sample_price, predicted_price)\n",
    "\n",
    "print(\"상품명:\", sample_title)\n",
    "print(\"판매 가격:\", f\"{sample_price:,}원\")\n",
    "print(\"예측 적정 가격:\", f\"{int(predicted_price):,}원\")\n",
    "print(\"가격 판단:\", price_status)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "64c8c53b-72c0-48c7-9bbd-337c78bbdde7",
   "metadata": {},
   "source": [
    "## 34. 가격 예측 모델 저장\n",
    "\n",
    "학습된 가격 예측 모델을 파일로 저장한다.\n",
    "\n",
    "저장된 모델은 이후 Streamlit 앱에서 불러와 사용할 수 있다.  \n",
    "즉, 사용자가 링크를 입력하면 앱이 제목과 가격을 추출하고, 저장된 모델이 적정 가격을 예측하는 구조로 연결된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 34,
   "id": "cd6faf8b-071a-443c-95eb-c5548a82dcc1",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "모델 저장 완료: used_item_price_model.pkl\n"
     ]
    }
   ],
   "source": [
    "import joblib\n",
    "\n",
    "joblib.dump(price_model, \"used_item_price_model.pkl\")\n",
    "\n",
    "print(\"모델 저장 완료: used_item_price_model.pkl\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0bfd739b-0abd-414d-99f8-95800bea2d09",
   "metadata": {},
   "source": [
    "# 링크 기반 중고거래 분석 서비스 구현\n",
    "\n",
    "## 35. 최종 서비스 흐름 설계\n",
    "\n",
    "앞 단계에서는 수집한 중고거래 데이터를 활용하여 머신러닝 기반 가격 예측 모델을 학습하였다.\n",
    "\n",
    "이제 최종 서비스 형태에 맞게 사용자가 중고거래 게시글 링크를 입력했을 때 다음 과정이 자동으로 실행되도록 구성한다.\n",
    "\n",
    "1. 사용자가 당근마켓 게시글 링크를 입력한다.\n",
    "2. Selenium을 이용해 해당 게시글 페이지에 접속한다.\n",
    "3. 게시글에서 상품 제목, 가격, 상세설명을 추출한다.\n",
    "4. 머신러닝 모델이 제목과 가격 정보를 바탕으로 적정 가격을 예측한다.\n",
    "5. 실제 판매 가격과 예측 가격을 비교하여 `저렴`, `적정`, `비쌈`으로 판단한다.\n",
    "6. LLM이 상세설명을 사용자가 선택한 언어로 번역하고, 거래 시 주의사항을 함께 안내한다.\n",
    "\n",
    "이 단계는 이후 Streamlit 앱에서 핵심 기능으로 사용된다."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18c9fa7f-3c60-419e-bc3b-20f1497d493b",
   "metadata": {},
   "source": [
    "## 36. 필요한 라이브러리 불러오기\n",
    "\n",
    "링크 기반 분석 기능을 만들기 위해 필요한 라이브러리를 불러온다.\n",
    "\n",
    "- `joblib`: 저장된 머신러닝 모델을 불러오기 위해 사용\n",
    "- `Selenium`: 사용자가 입력한 게시글 링크에서 상품 정보를 추출하기 위해 사용\n",
    "- `LangChain`: LLM을 활용한 다국어 번역과 설명 생성을 위해 사용\n",
    "- `dotenv`: OpenAI API Key를 `.env` 파일에서 불러오기 위해 사용"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 35,
   "id": "92d833c8-23af-432b-8086-fba46b89a189",
   "metadata": {},
   "outputs": [],
   "source": [
    "import re\n",
    "import time\n",
    "import joblib\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "\n",
    "import undetected_chromedriver as uc\n",
    "from selenium.webdriver.common.by import By\n",
    "\n",
    "from dotenv import load_dotenv\n",
    "from langchain_openai import ChatOpenAI\n",
    "from langchain_core.prompts import ChatPromptTemplate\n",
    "from langchain_core.output_parsers import StrOutputParser"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8e89fa80-2eeb-4815-99ff-02cea3f78a08",
   "metadata": {},
   "source": [
    "## 37. 가격 예측 모델 불러오기\n",
    "\n",
    "앞에서 학습하고 저장한 `used_item_price_model.pkl` 파일을 불러온다.\n",
    "\n",
    "이 모델은 상품 제목, 카테고리, 제목 길이를 입력받아 중고 상품의 적정 가격을 예측한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 36,
   "id": "fb8e70de-eeca-41c2-8f66-b4e328a18cc8",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "가격 예측 모델 불러오기 완료\n"
     ]
    }
   ],
   "source": [
    "price_model = joblib.load(\"used_item_price_model.pkl\")\n",
    "\n",
    "print(\"가격 예측 모델 불러오기 완료\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "00b6c468-5d70-472b-a99c-06e3decb1c9e",
   "metadata": {},
   "source": [
    "## 38. 상품명 기반 카테고리 생성 함수\n",
    "\n",
    "새로운 링크에서 추출한 상품 제목도 머신러닝 모델에 입력하려면 카테고리 정보가 필요하다.\n",
    "\n",
    "따라서 전처리 단계에서 사용했던 방식과 동일하게, 상품명에 포함된 키워드를 기준으로 카테고리를 생성하는 함수를 다시 정의한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 37,
   "id": "e67c9629-dcce-4922-a863-b51ace47cc65",
   "metadata": {},
   "outputs": [],
   "source": [
    "def make_category(title):\n",
    "    title = str(title).lower()\n",
    "    \n",
    "    if any(word in title for word in [\"아이폰\", \"갤럭시\", \"폰\", \"휴대폰\", \"노트북\", \"맥북\", \"아이패드\", \"에어팟\", \"버즈\", \"컴퓨터\", \"모니터\"]):\n",
    "        return \"전자기기\"\n",
    "    elif any(word in title for word in [\"책상\", \"의자\", \"침대\", \"서랍\", \"장롱\", \"테이블\", \"소파\", \"수납\"]):\n",
    "        return \"가구\"\n",
    "    elif any(word in title for word in [\"냉장고\", \"세탁기\", \"전자레인지\", \"청소기\", \"에어컨\", \"선풍기\", \"밥솥\"]):\n",
    "        return \"생활가전\"\n",
    "    elif any(word in title for word in [\"자전거\", \"운동\", \"헬스\", \"축구\", \"농구\", \"라켓\", \"골프\"]):\n",
    "        return \"스포츠\"\n",
    "    elif any(word in title for word in [\"책\", \"교재\", \"전공\", \"토익\", \"문제집\", \"소설\"]):\n",
    "        return \"도서\"\n",
    "    elif any(word in title for word in [\"옷\", \"패딩\", \"자켓\", \"신발\", \"운동화\", \"가방\", \"모자\"]):\n",
    "        return \"패션\"\n",
    "    else:\n",
    "        return \"기타\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4d234479-b3e3-4364-96e2-21886e4ee953",
   "metadata": {},
   "source": [
    "## 39. 가격 문자열 숫자 변환 함수\n",
    "\n",
    "링크에서 추출한 가격은 `30,000원`, `100,000원`, `무료나눔`과 같은 문자열 형태일 수 있다.\n",
    "\n",
    "머신러닝 모델과 가격 판단 함수에서 사용하기 위해 가격을 숫자형으로 변환한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 38,
   "id": "a393b387-bac6-455f-b312-e2e2f7768135",
   "metadata": {},
   "outputs": [],
   "source": [
    "def clean_price(price):\n",
    "    if pd.isna(price):\n",
    "        return np.nan\n",
    "    \n",
    "    price = str(price).strip()\n",
    "    \n",
    "    if \"무료\" in price or \"나눔\" in price:\n",
    "        return 0\n",
    "    \n",
    "    numbers = re.sub(r\"[^0-9]\", \"\", price)\n",
    "    \n",
    "    if numbers == \"\":\n",
    "        return np.nan\n",
    "    \n",
    "    return int(numbers)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "db31f5e6-19ef-4713-b4d7-d6ad9cb95bec",
   "metadata": {},
   "source": [
    "## 40. 가격 적정성 판단 함수\n",
    "\n",
    "머신러닝 모델이 예측한 적정 가격과 실제 판매 가격을 비교하여 가격 상태를 판단한다.\n",
    "\n",
    "판단 기준은 다음과 같다.\n",
    "\n",
    "- 실제 가격이 예측 가격보다 20% 이상 낮으면 `저렴`\n",
    "- 실제 가격이 예측 가격과 비슷하면 `적정`\n",
    "- 실제 가격이 예측 가격보다 20% 이상 높으면 `비쌈`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 39,
   "id": "f25e8fd4-70ff-48e8-a208-e644d0283ded",
   "metadata": {},
   "outputs": [],
   "source": [
    "def judge_price(actual_price, predicted_price):\n",
    "    if actual_price < predicted_price * 0.8:\n",
    "        return \"저렴\"\n",
    "    elif actual_price > predicted_price * 1.2:\n",
    "        return \"비쌈\"\n",
    "    else:\n",
    "        return \"적정\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c3aa14a2-0d13-45ab-912d-6b1f560be47c",
   "metadata": {},
   "source": [
    "## 41. 게시글 링크에서 상품 정보 추출 함수 수정\n",
    "\n",
    "기존 함수는 페이지 전체 텍스트를 기준으로 상세설명을 추출했기 때문에, 실제 상품 설명이 아니라 검색어, 메뉴, 추천 상품, 지역 정보 등이 섞이는 문제가 발생하였다.\n",
    "\n",
    "이를 해결하기 위해 상세설명 추출 방식을 수정하였다.\n",
    "\n",
    "수정된 방식은 다음과 같다.\n",
    "\n",
    "1. 제목은 URL의 상품명 부분을 우선 활용한다.\n",
    "2. 가격은 숫자 가격을 우선 찾고, 숫자 가격이 없을 때만 나눔/무료를 확인한다.\n",
    "3. 상세설명은 페이지 전체 텍스트가 아니라 `meta description` 또는 `og:description` 태그를 먼저 확인한다.\n",
    "4. meta 설명이 없을 경우에만 본문 텍스트에서 불필요한 메뉴 문구를 제거하여 설명 후보를 만든다.\n",
    "\n",
    "이 방식은 페이지 상단의 검색어, 추천 상품, 메뉴 텍스트가 상세설명에 섞이는 문제를 줄이기 위한 처리이다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 45,
   "id": "41f3a774-57c3-4075-90c7-13439e6efc80",
   "metadata": {},
   "outputs": [],
   "source": [
    "import re\n",
    "import time\n",
    "import urllib.parse\n",
    "import numpy as np\n",
    "from bs4 import BeautifulSoup\n",
    "import undetected_chromedriver as uc\n",
    "from selenium.webdriver.common.by import By\n",
    "\n",
    "\n",
    "def clean_price(price):\n",
    "    if price is None:\n",
    "        return np.nan\n",
    "    \n",
    "    price = str(price).strip()\n",
    "    \n",
    "    if price in [\"나눔\", \"무료\", \"무료나눔\"]:\n",
    "        return 0\n",
    "    \n",
    "    numbers = re.sub(r\"[^0-9]\", \"\", price)\n",
    "    \n",
    "    if numbers == \"\":\n",
    "        return np.nan\n",
    "    \n",
    "    return int(numbers)\n",
    "\n",
    "\n",
    "def get_title_from_url(url):\n",
    "    \"\"\"\n",
    "    당근 게시글 URL에 포함된 상품명 부분을 추출한다.\n",
    "    \"\"\"\n",
    "    try:\n",
    "        decoded_url = urllib.parse.unquote(url)\n",
    "        slug = decoded_url.split(\"/buy-sell/\")[-1].strip(\"/\")\n",
    "        \n",
    "        parts = slug.split(\"-\")\n",
    "        \n",
    "        # 마지막 게시글 ID 제거\n",
    "        if len(parts) > 1:\n",
    "            parts = parts[:-1]\n",
    "        \n",
    "        title = \" \".join(parts).strip()\n",
    "        return title\n",
    "    \n",
    "    except:\n",
    "        return \"제목 없음\"\n",
    "\n",
    "\n",
    "def clean_description_text(text, title):\n",
    "    \"\"\"\n",
    "    상세설명 후보에서 불필요한 문구를 제거한다.\n",
    "    \"\"\"\n",
    "    if text is None:\n",
    "        return \"상세설명 없음\"\n",
    "    \n",
    "    text = str(text).strip()\n",
    "    \n",
    "    remove_words = [\n",
    "        \"본문 바로가기\",\n",
    "        \"당근\",\n",
    "        \"중고거래\",\n",
    "        \"검색\",\n",
    "        \"로그인\",\n",
    "        \"회원가입\",\n",
    "        \"채팅\",\n",
    "        \"관심\",\n",
    "        \"조회\",\n",
    "        \"신고\",\n",
    "        \"끌올\",\n",
    "        \"판매중\",\n",
    "        \"예약중\",\n",
    "        \"거래완료\"\n",
    "    ]\n",
    "    \n",
    "    for word in remove_words:\n",
    "        text = text.replace(word, \" \")\n",
    "    \n",
    "    # 제목이 반복되면 제거\n",
    "    if title != \"제목 없음\":\n",
    "        text = text.replace(title, \" \")\n",
    "    \n",
    "    # 공백 정리\n",
    "    text = re.sub(r\"\\s+\", \" \", text).strip()\n",
    "    \n",
    "    if text == \"\":\n",
    "        return \"상세설명 없음\"\n",
    "    \n",
    "    return text\n",
    "\n",
    "\n",
    "def extract_item_info_from_link(url):\n",
    "    driver = uc.Chrome(version_main=150)\n",
    "    driver.get(url)\n",
    "    time.sleep(4)\n",
    "    \n",
    "    html = driver.page_source\n",
    "    body_text = driver.find_element(By.TAG_NAME, \"body\").text\n",
    "    \n",
    "    driver.quit()\n",
    "    \n",
    "    soup = BeautifulSoup(html, \"html.parser\")\n",
    "    lines = [line.strip() for line in body_text.split(\"\\n\") if line.strip()]\n",
    "    \n",
    "    # 1. 제목 추출\n",
    "    title = get_title_from_url(url)\n",
    "    \n",
    "    # meta title 보조 활용\n",
    "    og_title = soup.select_one(\"meta[property='og:title']\")\n",
    "    if og_title and og_title.get(\"content\"):\n",
    "        meta_title = og_title.get(\"content\").strip()\n",
    "        meta_title = meta_title.replace(\" | 당근\", \"\").replace(\" - 당근\", \"\").strip()\n",
    "        \n",
    "        # meta_title이 더 길고 구체적이면 사용\n",
    "        if len(meta_title) > len(title):\n",
    "            title = meta_title\n",
    "    \n",
    "    # 2. 가격 추출\n",
    "    price = \"가격 없음\"\n",
    "    \n",
    "    # 숫자 가격 우선\n",
    "    number_price_pattern = re.compile(r\"\\d[\\d,]*\\s*원\")\n",
    "    \n",
    "    for line in lines:\n",
    "        match = number_price_pattern.search(line)\n",
    "        if match:\n",
    "            price = match.group()\n",
    "            break\n",
    "    \n",
    "    # 숫자 가격이 없을 때만 무료/나눔 확인\n",
    "    if price == \"가격 없음\":\n",
    "        for line in lines:\n",
    "            if line.strip() in [\"나눔\", \"무료\", \"무료나눔\"]:\n",
    "                price = line.strip()\n",
    "                break\n",
    "    \n",
    "    # 3. 상세설명 추출\n",
    "    description = \"상세설명 없음\"\n",
    "    \n",
    "    # 1순위: og:description\n",
    "    og_desc = soup.select_one(\"meta[property='og:description']\")\n",
    "    if og_desc and og_desc.get(\"content\"):\n",
    "        description = og_desc.get(\"content\").strip()\n",
    "    \n",
    "    # 2순위: name=\"description\"\n",
    "    if description == \"상세설명 없음\":\n",
    "        meta_desc = soup.select_one(\"meta[name='description']\")\n",
    "        if meta_desc and meta_desc.get(\"content\"):\n",
    "            description = meta_desc.get(\"content\").strip()\n",
    "    \n",
    "    # 3순위: 본문 텍스트에서 후보 추출\n",
    "    if description == \"상세설명 없음\":\n",
    "        desc_candidates = []\n",
    "        \n",
    "        for line in lines:\n",
    "            if len(line) < 8:\n",
    "                continue\n",
    "            \n",
    "            # 불필요한 줄 제거\n",
    "            if \"본문 바로가기\" in line:\n",
    "                continue\n",
    "            if \"로드자전거\" in line or \"포켓몬카드\" in line or \"전기자전거\" in line:\n",
    "                continue\n",
    "            if \"당근\" in line:\n",
    "                continue\n",
    "            if \"검색\" in line:\n",
    "                continue\n",
    "            if \"로그인\" in line:\n",
    "                continue\n",
    "            if \"채팅\" in line:\n",
    "                continue\n",
    "            if \"관심\" in line:\n",
    "                continue\n",
    "            if \"조회\" in line:\n",
    "                continue\n",
    "            if \"신고\" in line:\n",
    "                continue\n",
    "            if title in line:\n",
    "                continue\n",
    "            if price != \"가격 없음\" and price in line:\n",
    "                continue\n",
    "            \n",
    "            desc_candidates.append(line)\n",
    "        \n",
    "        if len(desc_candidates) > 0:\n",
    "            description = \" \".join(desc_candidates[:5])\n",
    "    \n",
    "    description = clean_description_text(description, title)\n",
    "    \n",
    "    return {\n",
    "        \"제목\": title,\n",
    "        \"가격\": price,\n",
    "        \"가격_numeric\": clean_price(price),\n",
    "        \"상세설명\": description,\n",
    "        \"링크\": url\n",
    "    }"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e9ec805c-e189-4e93-8e32-9fea93f6dcd0",
   "metadata": {},
   "source": [
    "## 42. 게시글 링크 추출 테스트\n",
    "\n",
    "실제 중고거래 게시글 링크를 입력하여 제목, 가격, 상세설명이 정상적으로 추출되는지 확인한다.\n",
    "\n",
    "이 단계에서 추출 결과가 정확해야 이후 가격 판단과 번역 기능이 정상적으로 작동한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 46,
   "id": "e2b8fc51-fd1a-49c9-b8a2-1696080e32fe",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "{'제목': '삼성 갤럭시탭 울트라 S10 1TB WiFi (풀박스, 케이스, 필름포함) | 디지털기기 중고거래',\n",
       " '가격': '1,300,000원',\n",
       " '가격_numeric': 1300000,\n",
       " '상세설명': '삼성 갤럭시탭 울트라 s10 1TB 판매합니다. 필름 붙여져있고 케이스 씌워서 사용했습니다. 기능 전부다 정상 작동하구요 지문인식이랑 얼�굴인식은 사용안해서 굳이 검사 안했습니다. 블루투스 이어폰도 마찬가지로 연결안해서 사용해서 기능확인은 못했지만 갤럭시 어플연동되는거 보면 아마 문...',\n",
       " '링크': 'https://www.daangn.com/kr/buy-sell/%EC%82%BC%EC%84%B1-%EA%B0%A4%EB%9F%AD%EC%8B%9C%ED%83%AD-%EC%9A%B8%ED%8A%B8%EB%9D%BC-s10-1tb-wifi-%ED%92%80%EB%B0%95%EC%8A%A4-%EC%BC%80%EC%9D%B4%EC%8A%A4-%ED%95%84%EB%A6%84%ED%8F%AC%ED%95%A8-d6gn15af484r/'}"
      ]
     },
     "execution_count": 46,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "test_url = \"https://www.daangn.com/kr/buy-sell/%EC%82%BC%EC%84%B1-%EA%B0%A4%EB%9F%AD%EC%8B%9C%ED%83%AD-%EC%9A%B8%ED%8A%B8%EB%9D%BC-s10-1tb-wifi-%ED%92%80%EB%B0%95%EC%8A%A4-%EC%BC%80%EC%9D%B4%EC%8A%A4-%ED%95%84%EB%A6%84%ED%8F%AC%ED%95%A8-d6gn15af484r/\"\n",
    "\n",
    "item_info = extract_item_info_from_link(test_url)\n",
    "\n",
    "item_info"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bf901df7-8e18-4fd3-94f1-31377f6645f4",
   "metadata": {},
   "source": [
    "## 모델 예측 오류 원인 확인\n",
    "\n",
    "예측 가격이 실제 기대 가격보다 낮게 나온 이유를 확인하기 위해,  \n",
    "학습 데이터 안에 갤럭시탭, 태블릿, 아이패드와 같은 유사 상품이 얼마나 포함되어 있는지 확인한다.\n",
    "\n",
    "머신러닝 모델은 학습 데이터에 없는 상품군에 대해서는 정확한 가격을 예측하기 어렵다.  \n",
    "따라서 고가 태블릿 데이터가 부족하면 예측 가격이 전체 전자기기 평균 가격 쪽으로 낮게 나올 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 49,
   "id": "6a5ebbe4-4439-4f23-9434-579017a6c80d",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "태블릿 관련 데이터 개수: 25\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>가격_numeric</th>\n",
       "      <th>카테고리</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>70</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>1900000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3868</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>1590000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9877</th>\n",
       "      <td>갤탭</td>\n",
       "      <td>870000</td>\n",
       "      <td>기타</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6562</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>850000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2613</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>850000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3819</th>\n",
       "      <td>갤럭시탭s8</td>\n",
       "      <td>830000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4515</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>600000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>258</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>550000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5190</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>550000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2050</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>470000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9743</th>\n",
       "      <td>갤럭시탭</td>\n",
       "      <td>270000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3621</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>90000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>929</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>50000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5905</th>\n",
       "      <td>멀티탭</td>\n",
       "      <td>30000</td>\n",
       "      <td>기타</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1334</th>\n",
       "      <td>(새상품)멀티탭</td>\n",
       "      <td>20000</td>\n",
       "      <td>기타</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8884</th>\n",
       "      <td>아이패드</td>\n",
       "      <td>13000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13739</th>\n",
       "      <td>아이패드미니5</td>\n",
       "      <td>10000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>19642</th>\n",
       "      <td>멀티탭</td>\n",
       "      <td>10000</td>\n",
       "      <td>기타</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2990</th>\n",
       "      <td>갤럭시탭</td>\n",
       "      <td>8000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13325</th>\n",
       "      <td>휴대폰거치대(태블릿)</td>\n",
       "      <td>8000</td>\n",
       "      <td>전자기기</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "                제목  가격_numeric  카테고리\n",
       "70            아이패드     1900000  전자기기\n",
       "3868          아이패드     1590000  전자기기\n",
       "9877            갤탭      870000    기타\n",
       "6562          아이패드      850000  전자기기\n",
       "2613          아이패드      850000  전자기기\n",
       "3819        갤럭시탭s8      830000  전자기기\n",
       "4515          아이패드      600000  전자기기\n",
       "258           아이패드      550000  전자기기\n",
       "5190          아이패드      550000  전자기기\n",
       "2050          아이패드      470000  전자기기\n",
       "9743          갤럭시탭      270000  전자기기\n",
       "3621          아이패드       90000  전자기기\n",
       "929           아이패드       50000  전자기기\n",
       "5905           멀티탭       30000    기타\n",
       "1334      (새상품)멀티탭       20000    기타\n",
       "8884          아이패드       13000  전자기기\n",
       "13739      아이패드미니5       10000  전자기기\n",
       "19642          멀티탭       10000    기타\n",
       "2990          갤럭시탭        8000  전자기기\n",
       "13325  휴대폰거치대(태블릿)        8000  전자기기"
      ]
     },
     "execution_count": 49,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "tablet_keywords = [\"갤럭시탭\", \"갤럭시 탭\", \"아이패드\", \"태블릿\", \"탭\"]\n",
    "\n",
    "tablet_df = df_model[\n",
    "    df_model[\"제목\"].astype(str).str.contains(\"|\".join(tablet_keywords), case=False, na=False)\n",
    "]\n",
    "\n",
    "print(\"태블릿 관련 데이터 개수:\", len(tablet_df))\n",
    "\n",
    "tablet_df[[\"제목\", \"가격_numeric\", \"카테고리\"]].sort_values(\"가격_numeric\", ascending=False).head(20)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "633418a6-d208-44d0-9b52-518b21eed253",
   "metadata": {},
   "source": [
    "## 세부 카테고리 생성\n",
    "\n",
    "기존 모델에서는 전자기기 상품이 모두 하나의 카테고리로 묶여 있었다.\n",
    "\n",
    "하지만 전자기기 안에서도 에어팟, 스마트폰, 태블릿, 노트북은 가격대가 크게 다르다.  \n",
    "따라서 상품명 키워드를 기준으로 세부 카테고리를 생성하여 모델이 상품군별 가격 차이를 더 잘 학습하도록 한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 50,
   "id": "6dfedd54-6d45-4ba3-86a5-c730b124b615",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>제목</th>\n",
       "      <th>카테고리</th>\n",
       "      <th>세부카테고리</th>\n",
       "      <th>가격_numeric</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>5,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>기타</td>\n",
       "      <td>5000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>10,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>기타</td>\n",
       "      <td>10000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>20,000원 이하</td>\n",
       "      <td>기타</td>\n",
       "      <td>기타</td>\n",
       "      <td>20000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>아동</td>\n",
       "      <td>기타</td>\n",
       "      <td>기타</td>\n",
       "      <td>3000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>스피커</td>\n",
       "      <td>기타</td>\n",
       "      <td>기타</td>\n",
       "      <td>175000</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "           제목 카테고리 세부카테고리  가격_numeric\n",
       "0   5,000원 이하   기타     기타        5000\n",
       "1  10,000원 이하   기타     기타       10000\n",
       "2  20,000원 이하   기타     기타       20000\n",
       "3          아동   기타     기타        3000\n",
       "4         스피커   기타     기타      175000"
      ]
     },
     "execution_count": 50,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def make_detail_category(title):\n",
    "    title = str(title).lower()\n",
    "    \n",
    "    if any(word in title for word in [\"갤럭시탭\", \"갤럭시 탭\", \"아이패드\", \"태블릿\", \"탭\"]):\n",
    "        return \"태블릿\"\n",
    "    elif any(word in title for word in [\"아이폰\", \"갤럭시\", \"휴대폰\", \"스마트폰\", \"폰\"]):\n",
    "        return \"스마트폰\"\n",
    "    elif any(word in title for word in [\"노트북\", \"맥북\", \"그램\", \"갤럭시북\"]):\n",
    "        return \"노트북\"\n",
    "    elif any(word in title for word in [\"에어팟\", \"버즈\", \"이어폰\", \"헤드셋\", \"헤드폰\"]):\n",
    "        return \"이어폰\"\n",
    "    elif any(word in title for word in [\"모니터\", \"컴퓨터\", \"pc\", \"본체\", \"키보드\", \"마우스\"]):\n",
    "        return \"컴퓨터주변기기\"\n",
    "    elif any(word in title for word in [\"책상\", \"의자\", \"침대\", \"서랍\", \"테이블\", \"소파\"]):\n",
    "        return \"가구\"\n",
    "    elif any(word in title for word in [\"냉장고\", \"세탁기\", \"전자레인지\", \"청소기\", \"에어컨\", \"선풍기\", \"밥솥\"]):\n",
    "        return \"생활가전\"\n",
    "    elif any(word in title for word in [\"자전거\", \"운동\", \"헬스\", \"축구\", \"농구\", \"골프\"]):\n",
    "        return \"스포츠\"\n",
    "    elif any(word in title for word in [\"책\", \"교재\", \"전공\", \"토익\", \"문제집\"]):\n",
    "        return \"도서\"\n",
    "    elif any(word in title for word in [\"옷\", \"패딩\", \"자켓\", \"신발\", \"운동화\", \"가방\", \"모자\"]):\n",
    "        return \"패션\"\n",
    "    else:\n",
    "        return \"기타\"\n",
    "\n",
    "df_model[\"세부카테고리\"] = df_model[\"제목\"].apply(make_detail_category)\n",
    "\n",
    "df_model[[\"제목\", \"카테고리\", \"세부카테고리\", \"가격_numeric\"]].head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f8d89b65-1e92-4ca8-8540-b10162018885",
   "metadata": {},
   "source": [
    "## 개선된 가격 예측 모델 학습\n",
    "\n",
    "기존 모델은 카테고리 구분이 너무 넓어 고가 전자기기의 가격을 낮게 예측하는 문제가 있었다.\n",
    "\n",
    "이를 보완하기 위해 세부 카테고리를 추가하고, 상품 제목의 단어 정보를 TF-IDF로 변환하여 모델에 입력한다.  \n",
    "이를 통해 모델이 `갤럭시탭`, `아이패드`, `노트북`과 같은 고가 상품 키워드를 더 잘 반영하도록 한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 51,
   "id": "b840ff24-5608-4aa5-8a38-3ca75371c956",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "MAE: 97019.6\n",
      "RMSE: 201316.83\n",
      "R2 Score: 0.088\n"
     ]
    }
   ],
   "source": [
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import OneHotEncoder\n",
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "from sklearn.ensemble import RandomForestRegressor\n",
    "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n",
    "import numpy as np\n",
    "import joblib\n",
    "\n",
    "ml_df = df_model[[\"제목\", \"세부카테고리\", \"제목길이\", \"가격_numeric\"]].dropna().copy()\n",
    "\n",
    "X = ml_df[[\"제목\", \"세부카테고리\", \"제목길이\"]]\n",
    "y = ml_df[\"가격_numeric\"]\n",
    "\n",
    "X_train, X_test, y_train, y_test = train_test_split(\n",
    "    X, y,\n",
    "    test_size=0.2,\n",
    "    random_state=42\n",
    ")\n",
    "\n",
    "preprocessor = ColumnTransformer(\n",
    "    transformers=[\n",
    "        (\"title_tfidf\", TfidfVectorizer(max_features=500, ngram_range=(1, 2)), \"제목\"),\n",
    "        (\"category_ohe\", OneHotEncoder(handle_unknown=\"ignore\"), [\"세부카테고리\"]),\n",
    "        (\"num\", \"passthrough\", [\"제목길이\"])\n",
    "    ]\n",
    ")\n",
    "\n",
    "price_model = Pipeline(\n",
    "    steps=[\n",
    "        (\"preprocessor\", preprocessor),\n",
    "        (\"regressor\", RandomForestRegressor(\n",
    "            n_estimators=300,\n",
    "            random_state=42\n",
    "        ))\n",
    "    ]\n",
    ")\n",
    "\n",
    "price_model.fit(X_train, y_train)\n",
    "\n",
    "y_pred = price_model.predict(X_test)\n",
    "\n",
    "mae = mean_absolute_error(y_test, y_pred)\n",
    "rmse = np.sqrt(mean_squared_error(y_test, y_pred))\n",
    "r2 = r2_score(y_test, y_pred)\n",
    "\n",
    "print(\"MAE:\", round(mae, 2))\n",
    "print(\"RMSE:\", round(rmse, 2))\n",
    "print(\"R2 Score:\", round(r2, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0df550bf-e990-4741-9222-83a4fe3fd510",
   "metadata": {},
   "source": [
    "## 43. 추출한 상품 정보로 가격 예측하기\n",
    "\n",
    "앞 단계에서 중고거래 게시글 링크를 입력하여 상품 제목, 가격, 상세설명을 추출하였다.\n",
    "\n",
    "이번 단계에서는 추출된 상품 정보를 머신러닝 가격 예측 모델에 입력하여 적정 가격을 예측한다.\n",
    "\n",
    "가격 예측 과정은 다음과 같다.\n",
    "\n",
    "1. 추출된 상품 제목을 가져온다.\n",
    "2. 상품 제목에 포함된 키워드를 기준으로 카테고리를 생성한다.\n",
    "3. 상품 제목의 길이를 계산한다.\n",
    "4. 저장된 머신러닝 모델에 입력하여 적정 가격을 예측한다.\n",
    "5. 실제 판매 가격과 예측 가격을 비교하여 `저렴`, `적정`, `비쌈`으로 판단한다.\n",
    "\n",
    "단, 링크에서 가격이 `나눔`, `가격 없음` 등으로 잘못 추출될 수 있으므로, 실제 앱에서는 사용자가 가격을 직접 입력할 수 있도록 보완하는 것이 좋다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 52,
   "id": "85e9e68c-252a-4813-b3cb-b2e246b7b215",
   "metadata": {},
   "outputs": [],
   "source": [
    "def predict_price_from_item(item_info, manual_price=None):\n",
    "    title = item_info[\"제목\"]\n",
    "    \n",
    "    if manual_price is not None:\n",
    "        actual_price = manual_price\n",
    "    else:\n",
    "        actual_price = item_info[\"가격_numeric\"]\n",
    "    \n",
    "    if pd.isna(actual_price):\n",
    "        return {\n",
    "            \"제목\": title,\n",
    "            \"세부카테고리\": make_detail_category(title),\n",
    "            \"실제가격\": \"가격 없음\",\n",
    "            \"예측적정가격\": None,\n",
    "            \"가격판단\": \"가격 입력 필요\"\n",
    "        }\n",
    "    \n",
    "    actual_price = int(actual_price)\n",
    "    \n",
    "    detail_category = make_detail_category(title)\n",
    "    title_length = len(title)\n",
    "    \n",
    "    input_df = pd.DataFrame([{\n",
    "        \"제목\": title,\n",
    "        \"세부카테고리\": detail_category,\n",
    "        \"제목길이\": title_length\n",
    "    }])\n",
    "    \n",
    "    predicted_price = price_model.predict(input_df)[0]\n",
    "    price_status = judge_price(actual_price, predicted_price)\n",
    "    \n",
    "    return {\n",
    "        \"제목\": title,\n",
    "        \"세부카테고리\": detail_category,\n",
    "        \"실제가격\": actual_price,\n",
    "        \"예측적정가격\": int(predicted_price),\n",
    "        \"가격판단\": price_status\n",
    "    }"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0a21b697-4c75-4237-868a-7640c8262416",
   "metadata": {},
   "source": [
    "## 44. 가격 예측 결과 확인\n",
    "\n",
    "링크에서 추출한 상품 정보를 머신러닝 모델에 입력하여 가격 예측 결과를 확인한다.\n",
    "\n",
    "현재 당근마켓 페이지 구조상 가격이 자동으로 정확히 추출되지 않을 수 있으므로, 테스트 단계에서는 실제 판매 가격을 직접 입력하여 가격 판단 기능을 확인한다.\n",
    "\n",
    "이 방식은 최종 Streamlit 앱에서도 사용할 수 있다.  \n",
    "즉, 링크에서 가격이 자동 추출되면 그대로 사용하고, 가격이 없거나 잘못 추출되면 사용자가 직접 가격을 입력하도록 구성할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 55,
   "id": "cb5ec6c9-a353-4c20-958b-4c893366ce43",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "{'제목': '삼성 갤럭시탭 울트라 S10 1TB WiFi (풀박스, 케이스, 필름포함) | 디지털기기 중고거래',\n",
       " '세부카테고리': '태블릿',\n",
       " '실제가격': 900000,\n",
       " '예측적정가격': 82360,\n",
       " '가격판단': '비쌈'}"
      ]
     },
     "execution_count": 55,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# 테스트용 실제 판매 가격 입력\n",
    "manual_price = 900000\n",
    "\n",
    "price_result = predict_price_from_item(\n",
    "    item_info,\n",
    "    manual_price=manual_price\n",
    ")\n",
    "\n",
    "price_result"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0164141c-4482-42cc-bdfa-e62b9deb2d18",
   "metadata": {},
   "source": [
    "## 모델 한계\n",
    "\n",
    "본 모델은 수집된 중고거래 데이터를 기반으로 적정 가격을 예측하지만,  \n",
    "학습 데이터에 특정 고가 상품군이 충분히 포함되지 않은 경우 예측 가격이 실제 시세보다 낮게 나올 수 있다.\n",
    "\n",
    "예를 들어 갤럭시탭 울트라와 같은 고가 태블릿 상품은 일반 전자기기 평균 가격보다 훨씬 높기 때문에,  \n",
    "태블릿 관련 학습 데이터가 부족하면 모델이 가격을 과소 예측할 수 있다.\n",
    "\n",
    "따라서 본 서비스의 예측 가격은 절대적인 시세가 아니라,  \n",
    "수집 데이터 기준의 참고 가격으로 해석해야 한다."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cb6f5f4b-cdee-48a2-893b-cb9d489080af",
   "metadata": {},
   "source": [
    "## 45. LLM 다국어 설명 기능 준비\n",
    "\n",
    "본 프로젝트의 주요 대상자는 한국어 중고거래 게시글을 이해하기 어려운 유학생이다.\n",
    "\n",
    "따라서 머신러닝 모델이 가격 적정성을 판단한 뒤, LLM을 활용하여 상품 정보를 유학생이 원하는 언어로 설명하는 기능을 구현한다.\n",
    "\n",
    "LLM은 다음과 같은 역할을 수행한다.\n",
    "\n",
    "1. 상품 제목과 상세설명을 쉬운 외국어로 번역한다.\n",
    "2. 머신러닝 모델의 가격 판단 결과를 이해하기 쉽게 설명한다.\n",
    "3. 거래 전에 확인해야 할 주의사항을 안내한다.\n",
    "4. 판매자에게 보낼 수 있는 간단한 메시지를 생성한다.\n",
    "\n",
    "이 기능을 통해 유학생은 한국어 중고거래 게시글을 더 쉽게 이해하고, 가격이 합리적인지 판단할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 56,
   "id": "f4c3f342-fade-48d0-a840-74916a5dacfa",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Requirement already satisfied: langchain in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (1.3.14)\n",
      "Requirement already satisfied: langchain-openai in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (1.4.1)\n",
      "Requirement already satisfied: python-dotenv in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (1.2.2)\n",
      "Requirement already satisfied: langchain-core<2.0.0,>=1.4.9 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain) (1.5.1)\n",
      "Requirement already satisfied: langgraph<1.3.0,>=1.2.5 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain) (1.2.9)\n",
      "Requirement already satisfied: pydantic<3.0.0,>=2.7.4 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain) (2.12.4)\n",
      "Requirement already satisfied: jsonpatch<2.0.0,>=1.33.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (1.33)\n",
      "Requirement already satisfied: langchain-protocol>=0.0.17 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (0.0.18)\n",
      "Requirement already satisfied: langsmith<1.0.0,>=0.3.45 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (0.10.10)\n",
      "Requirement already satisfied: packaging>=23.2.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (25.0)\n",
      "Requirement already satisfied: pyyaml<7.0.0,>=5.3.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (6.0.3)\n",
      "Requirement already satisfied: tenacity!=8.4.0,<10.0.0,>=8.1.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (9.1.2)\n",
      "Requirement already satisfied: typing-extensions<5.0.0,>=4.7.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (4.15.0)\n",
      "Requirement already satisfied: uuid-utils<1.0,>=0.12.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-core<2.0.0,>=1.4.9->langchain) (0.17.0)\n",
      "Requirement already satisfied: jsonpointer>=1.9 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from jsonpatch<2.0.0,>=1.33.0->langchain-core<2.0.0,>=1.4.9->langchain) (3.0.0)\n",
      "Requirement already satisfied: langgraph-checkpoint<5.0.0,>=4.1.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph<1.3.0,>=1.2.5->langchain) (4.1.1)\n",
      "Requirement already satisfied: langgraph-prebuilt<1.2.0,>=1.1.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph<1.3.0,>=1.2.5->langchain) (1.1.0)\n",
      "Requirement already satisfied: langgraph-sdk<0.5.0,>=0.4.2 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph<1.3.0,>=1.2.5->langchain) (0.4.2)\n",
      "Requirement already satisfied: xxhash>=3.5.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph<1.3.0,>=1.2.5->langchain) (3.8.1)\n",
      "Requirement already satisfied: ormsgpack>=1.12.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph-checkpoint<5.0.0,>=4.1.0->langgraph<1.3.0,>=1.2.5->langchain) (1.12.2)\n",
      "Requirement already satisfied: httpx>=0.25.2 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (0.28.1)\n",
      "Requirement already satisfied: orjson>=3.11.5 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (3.11.9)\n",
      "Requirement already satisfied: websockets<16,>=14 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (15.0.1)\n",
      "Requirement already satisfied: anyio>=3.5.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (4.10.0)\n",
      "Requirement already satisfied: distro>=1.7.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (1.9.0)\n",
      "Requirement already satisfied: requests-toolbelt>=1.0.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (1.0.0)\n",
      "Requirement already satisfied: requests>=2.0.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (2.32.5)\n",
      "Requirement already satisfied: sniffio>=1.1 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (1.3.0)\n",
      "Requirement already satisfied: zstandard>=0.23.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (0.24.0)\n",
      "Requirement already satisfied: certifi in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from httpx>=0.25.2->langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (2026.5.20)\n",
      "Requirement already satisfied: httpcore==1.* in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from httpx>=0.25.2->langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (1.0.9)\n",
      "Requirement already satisfied: idna in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from httpx>=0.25.2->langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (3.11)\n",
      "Requirement already satisfied: h11>=0.16 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from httpcore==1.*->httpx>=0.25.2->langgraph-sdk<0.5.0,>=0.4.2->langgraph<1.3.0,>=1.2.5->langchain) (0.16.0)\n",
      "Requirement already satisfied: annotated-types>=0.6.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from pydantic<3.0.0,>=2.7.4->langchain) (0.6.0)\n",
      "Requirement already satisfied: pydantic-core==2.41.5 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from pydantic<3.0.0,>=2.7.4->langchain) (2.41.5)\n",
      "Requirement already satisfied: typing-inspection>=0.4.2 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from pydantic<3.0.0,>=2.7.4->langchain) (0.4.2)\n",
      "Requirement already satisfied: openai<3.0.0,>=2.45.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-openai) (2.48.0)\n",
      "Requirement already satisfied: tiktoken<1.0.0,>=0.7.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from langchain-openai) (0.13.0)\n",
      "Requirement already satisfied: jiter<1,>=0.10.0 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from openai<3.0.0,>=2.45.0->langchain-openai) (0.16.0)\n",
      "Requirement already satisfied: tqdm>4 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from openai<3.0.0,>=2.45.0->langchain-openai) (4.67.1)\n",
      "Requirement already satisfied: regex in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from tiktoken<1.0.0,>=0.7.0->langchain-openai) (2025.9.1)\n",
      "Requirement already satisfied: charset_normalizer<4,>=2 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (3.4.4)\n",
      "Requirement already satisfied: urllib3<3,>=1.21.1 in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core<2.0.0,>=1.4.9->langchain) (2.7.0)\n",
      "Requirement already satisfied: colorama in c:\\users\\tonyt\\anaconda3\\lib\\site-packages (from tqdm>4->openai<3.0.0,>=2.45.0->langchain-openai) (0.4.6)\n"
     ]
    }
   ],
   "source": [
    "#!pip install langchain langchain-openai python-dotenv"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 57,
   "id": "14785739-886f-46c7-99d8-a4d12b7a113c",
   "metadata": {},
   "outputs": [],
   "source": [
    "from dotenv import load_dotenv\n",
    "from langchain_openai import ChatOpenAI\n",
    "from langchain_core.prompts import ChatPromptTemplate\n",
    "from langchain_core.output_parsers import StrOutputParser"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dbc783af-84af-4fda-8df8-c2777ea61566",
   "metadata": {},
   "source": [
    "## 46. OpenAI API Key 불러오기\n",
    "\n",
    "LLM 기능을 사용하기 위해 OpenAI API Key를 불러온다.\n",
    "\n",
    "API Key는 코드에 직접 작성하지 않고 `.env` 파일에 저장한 뒤 불러오는 방식을 사용한다.  \n",
    "이 방식은 API Key가 노트북이나 GitHub에 노출되는 것을 방지하기 위한 방법이다.\n",
    "\n",
    "`.env` 파일에는 다음과 같은 형식으로 API Key를 작성한다.\n",
    "\n",
    "OPENAI_API_KEY=본인의_API_KEY"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 59,
   "id": "f4af6b72-0ee6-475f-8935-5ae222da7fcb",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "LLM 준비 완료\n"
     ]
    }
   ],
   "source": [
    "import os\n",
    "\n",
    "load_dotenv()\n",
    "\n",
    "llm = ChatOpenAI(\n",
    "    model=\"gpt-4o-mini\",\n",
    "    temperature=0.3\n",
    ")\n",
    "\n",
    "print(\"LLM 준비 완료\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "936f8767-79bf-4eed-8779-2dec4efaace0",
   "metadata": {},
   "source": [
    "## 47. LLM 프롬프트 작성\n",
    "\n",
    "LLM에게 상품 정보와 가격 판단 결과를 전달하기 위한 프롬프트를 작성한다.\n",
    "\n",
    "프롬프트에는 상품명, 카테고리, 실제 판매 가격, 예측 참고 가격, 가격 판단 결과, 상세설명을 포함한다.\n",
    "\n",
    "LLM은 이 정보를 바탕으로 유학생이 이해하기 쉬운 다국어 설명을 생성한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 60,
   "id": "b4bea696-34ab-48b2-adea-bd13de1adb02",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "LLM 프롬프트 준비 완료\n"
     ]
    }
   ],
   "source": [
    "prompt = ChatPromptTemplate.from_template(\"\"\"\n",
    "너는 부산외국어대학교 유학생을 위한 중고거래 도우미야.\n",
    "\n",
    "아래 상품 정보를 바탕으로 유학생이 이해하기 쉽게 설명해줘.\n",
    "반드시 {language}로 작성해줘.\n",
    "\n",
    "[상품 정보]\n",
    "상품명: {title}\n",
    "카테고리: {category}\n",
    "판매 가격: {actual_price}원\n",
    "수집 데이터 기준 참고 가격: {predicted_price}원\n",
    "가격 판단: {price_status}\n",
    "상세설명: {description}\n",
    "\n",
    "답변 형식은 아래를 반드시 지켜줘.\n",
    "\n",
    "### 1. Product Summary\n",
    "상품이 무엇인지 쉽게 설명해줘.\n",
    "\n",
    "### 2. Price Check\n",
    "판매 가격이 참고 가격과 비교했을 때 저렴한지, 적정한지, 비싼지 설명해줘.\n",
    "단, 참고 가격은 수집 데이터 기반 예측값이므로 절대적인 시세가 아니라고 자연스럽게 말해줘.\n",
    "\n",
    "### 3. Things to Check Before Buying\n",
    "구매 전에 확인해야 할 점을 알려줘.\n",
    "\n",
    "### 4. Message to Seller\n",
    "판매자에게 보낼 수 있는 짧은 메시지를 만들어줘.\n",
    "\n",
    "### 5. Simple Korean Words\n",
    "게시글에 나오는 중요한 한국어 단어를 {language}로 설명해줘.\n",
    "\"\"\")\n",
    "\n",
    "translation_chain = prompt | llm | StrOutputParser()\n",
    "\n",
    "print(\"LLM 프롬프트 준비 완료\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d22ea5b6-dce7-470b-89d4-77ea1f48fbfc",
   "metadata": {},
   "source": [
    "## 48. LLM 다국어 설명 생성 함수\n",
    "\n",
    "상품 정보와 머신러닝 가격 판단 결과를 LLM에 전달하여 다국어 설명을 생성하는 함수를 만든다.\n",
    "\n",
    "이 함수는 최종 서비스에서 유학생이 선택한 언어에 따라 상품 정보를 번역하고, 가격 판단 결과와 거래 주의사항을 함께 제공하는 역할을 한다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 62,
   "id": "404190db-7e6a-4dd4-8b41-03feb9e44a78",
   "metadata": {},
   "outputs": [],
   "source": [
    "def generate_multilingual_result(item_info, price_result, language=\"English\"):\n",
    "    \"\"\"\n",
    "    상품 정보와 가격 판단 결과를 바탕으로\n",
    "    LLM이 다국어 설명을 생성하는 함수\n",
    "    \"\"\"\n",
    "    \n",
    "    # 세부카테고리 또는 카테고리 둘 다 대응 가능하게 처리\n",
    "    category = price_result.get(\"세부카테고리\", price_result.get(\"카테고리\", \"기타\"))\n",
    "    \n",
    "    answer = translation_chain.invoke({\n",
    "        \"language\": language,\n",
    "        \"title\": item_info[\"제목\"],\n",
    "        \"category\": category,\n",
    "        \"actual_price\": price_result[\"실제가격\"],\n",
    "        \"predicted_price\": price_result[\"예측적정가격\"],\n",
    "        \"price_status\": price_result[\"가격판단\"],\n",
    "        \"description\": item_info[\"상세설명\"]\n",
    "    })\n",
    "    \n",
    "    return answer"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2e7bf7b2-6805-4350-8764-679a12166059",
   "metadata": {},
   "source": [
    "## 49. LLM 다국어 설명 테스트\n",
    "\n",
    "앞에서 추출한 상품 정보와 머신러닝 가격 판단 결과를 LLM에 입력하여 영어 설명을 생성한다.\n",
    "\n",
    "이 단계가 성공하면, 최종 앱에서 유학생이 영어, 일본어, 중국어 등 원하는 언어를 선택했을 때 상품 정보를 쉽게 이해할 수 있도록 제공할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 63,
   "id": "a719ccdd-ba9e-4537-9670-c1ed32048688",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "### 1. Product Summary\n",
      "This product is a Samsung Galaxy Tab Ultra S10 with 1TB of storage. It comes with the full box, a protective case, and a screen protector already applied. The tablet is in good working condition, and all functions are operational. However, the fingerprint and face recognition features have not been tested, and the Bluetooth connectivity with earphones has not been used either.\n",
      "\n",
      "### 2. Price Check\n",
      "The selling price is 900,000 KRW, which is considered expensive compared to the reference price of 82,360 KRW based on collected data. However, please keep in mind that the reference price is an estimate and may not reflect the actual market value.\n",
      "\n",
      "### 3. Things to Check Before Buying\n",
      "Before making a purchase, you should check the following:\n",
      "- Ensure that the tablet is in good physical condition without any major scratches or damages.\n",
      "- Test the main functions of the tablet, such as the touchscreen, Wi-Fi connectivity, and camera.\n",
      "- Confirm if the fingerprint and face recognition features work properly, even if they haven't been tested by the seller.\n",
      "- Ask if there are any warranty options or return policies available.\n",
      "\n",
      "### 4. Message to Seller\n",
      "\"Hello! I'm interested in your Samsung Galaxy Tab Ultra S10. Can you please confirm if all features are working properly? Thank you!\"\n",
      "\n",
      "### 5. Simple Korean Words\n",
      "- **태블릿 (Tablet)**: A portable touchscreen computer.\n",
      "- **판매 가격 (Selling Price)**: The price at which the item is being sold.\n",
      "- **정상 작동 (Normal Operation)**: The item is functioning as expected.\n",
      "- **필름 (Film)**: A protective layer applied to the screen.\n",
      "- **케이스 (Case)**: A protective cover for the tablet.\n"
     ]
    }
   ],
   "source": [
    "final_answer = generate_multilingual_result(\n",
    "    item_info=item_info,\n",
    "    price_result=price_result,\n",
    "    language=\"English\"\n",
    ")\n",
    "\n",
    "print(final_answer)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 64,
   "id": "d637a82e-3118-4d95-8b19-f38335e1d364",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "### 1. Product Summary\n",
      "これは「Samsung Galaxy Tab Ultra S10 1TB WiFi」というタブレットです。フルボックス、ケース、フィルムが含まれており、状態は良好です。フィルムが貼られていて、ケースも使っていたため、傷が少ないと思われます。すべての機能は正常に動作しているとのことですが、指紋認証や顔認証は使用していないため、確認はされていません。\n",
      "\n",
      "### 2. Price Check\n",
      "販売価格は900,000ウォンで、参考価格は82,360ウォンです。参考価格はデータに基づく予測値なので、絶対的な市場価格ではありませんが、販売価格はかなり高いと考えられます。特に、同じモデルの中古品が他の場所でどのように販売されているかを確認することをお勧めします。\n",
      "\n",
      "### 3. Things to Check Before Buying\n",
      "購入前に確認すべき点は以下の通りです：\n",
      "- タブレットの状態（傷や使用感）\n",
      "- 指紋認証や顔認証の動作確認\n",
      "- 付属品（ケースやフィルム）の状態\n",
      "- 価格が他の同様の製品と比較して適正かどうか\n",
      "\n",
      "### 4. Message to Seller\n",
      "「こんにちは！このタブレットについていくつか質問があります。指紋認証と顔認証は正常に動作しますか？また、他に気になる点があれば教えてください。ありがとうございます！」\n",
      "\n",
      "### 5. Simple Korean Words\n",
      "- タブレット (태블릿): 電子機器の一種で、画面が大きく、タッチ操作ができる。\n",
      "- 판매 가격 (판매 가격): 商品が売られている価格。\n",
      "- 상태 (상태): 商品の状態や品質。\n",
      "- 기능 (기능): 商品が持っている機能や特性。\n",
      "- 확인 (확인): 確認すること、チェックすること。\n"
     ]
    }
   ],
   "source": [
    "final_answer_jp = generate_multilingual_result(\n",
    "    item_info=item_info,\n",
    "    price_result=price_result,\n",
    "    language=\"Japanese\"\n",
    ")\n",
    "\n",
    "print(final_answer_jp)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 65,
   "id": "68a6ebdc-eb47-4f1c-9ad3-9c1c5daefaad",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "### 1. Product Summary\n",
      "这是一款三星的Galaxy Tab Ultra S10平板电脑，存储容量为1TB，支持WiFi。产品包含完整包装、保护壳和屏幕保护膜。平板电脑的所有功能正常运作，使用时已经贴上了保护膜并放入了保护壳。\n",
      "\n",
      "### 2. Price Check\n",
      "这款平板的售价为900,000韩元，相比参考价格82,360韩元来说，价格显得比较贵。不过，请注意，参考价格是基于收集的数据预测的，并不代表绝对的市场价格。\n",
      "\n",
      "### 3. Things to Check Before Buying\n",
      "在购买之前，您需要确认以下几点：\n",
      "1. 确认平板电脑的功能是否完全正常，包括触摸屏、WiFi连接和其他应用程序。\n",
      "2. 检查设备是否有任何划痕或损坏。\n",
      "3. 确认是否可以测试指纹识别和面部识别功能。\n",
      "4. 确认卖家是否提供退换货政策。\n",
      "\n",
      "### 4. Message to Seller\n",
      "您好！我对您的三星Galaxy Tab Ultra S10感兴趣。请问可以确认一下设备的功能是否正常吗？谢谢！\n",
      "\n",
      "### 5. Simple Korean Words\n",
      "- 태블릿 (平板电脑): 一种便携式电子设备，通常用于上网、观看视频和阅读。\n",
      "- 판매 가격 (销售价格): 商品的出售价格。\n",
      "- 참고 가격 (参考价格): 基于市场数据的预测价格，用于比较商品的实际售价。\n"
     ]
    }
   ],
   "source": [
    "final_answer_zh = generate_multilingual_result(\n",
    "    item_info=item_info,\n",
    "    price_result=price_result,\n",
    "    language=\"Chinese\"\n",
    ")\n",
    "\n",
    "print(final_answer_zh)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c67e6fc4-ee7c-4580-86a9-cfdb7f7553f5",
   "metadata": {},
   "source": [
    "## 50. 전체 기능 통합 함수 만들기\n",
    "\n",
    "앞에서 구현한 기능들을 하나의 함수로 통합한다.\n",
    "\n",
    "이 함수는 사용자가 중고거래 게시글 링크를 입력하면 다음 과정을 한 번에 수행한다.\n",
    "\n",
    "1. 게시글 링크에서 상품 제목, 가격, 상세설명을 추출한다.\n",
    "2. 사용자가 입력한 실제 가격 또는 추출된 가격을 바탕으로 머신러닝 가격 예측을 수행한다.\n",
    "3. 실제 판매 가격과 예측 참고 가격을 비교하여 가격 상태를 판단한다.\n",
    "4. LLM을 활용하여 영어, 일본어, 중국어 등 선택한 언어로 상품 설명을 생성한다.\n",
    "5. 최종 결과를 하나의 딕셔너리 형태로 반환한다.\n",
    "\n",
    "이 함수는 이후 Streamlit 앱에서 핵심 기능으로 사용된다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 66,
   "id": "c8904aa9-e262-4093-8f9f-1936914031d3",
   "metadata": {},
   "outputs": [],
   "source": [
    "def analyze_used_item_link(url, language=\"English\", manual_price=None):\n",
    "    \"\"\"\n",
    "    중고거래 게시글 링크를 입력받아\n",
    "    상품 정보 추출, 가격 예측, 다국어 설명 생성을 한 번에 수행하는 함수\n",
    "    \"\"\"\n",
    "    \n",
    "    # 1. 링크에서 상품 정보 추출\n",
    "    item_info = extract_item_info_from_link(url)\n",
    "    \n",
    "    # 2. 머신러닝 가격 예측 및 가격 판단\n",
    "    price_result = predict_price_from_item(\n",
    "        item_info=item_info,\n",
    "        manual_price=manual_price\n",
    "    )\n",
    "    \n",
    "    # 3. LLM 다국어 설명 생성\n",
    "    ai_result = generate_multilingual_result(\n",
    "        item_info=item_info,\n",
    "        price_result=price_result,\n",
    "        language=language\n",
    "    )\n",
    "    \n",
    "    # 4. 최종 결과 반환\n",
    "    return {\n",
    "        \"상품정보\": item_info,\n",
    "        \"가격판단결과\": price_result,\n",
    "        \"AI설명\": ai_result\n",
    "    }"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "432edf17-c0fe-4de4-86ff-cd08025ac102",
   "metadata": {},
   "source": [
    "## 51. 전체 기능 테스트\n",
    "\n",
    "전체 기능이 정상적으로 연결되는지 확인한다.\n",
    "\n",
    "테스트에서는 당근마켓 게시글 링크와 실제 판매 가격을 입력하고, 출력 언어를 선택한다.  \n",
    "가격이 자동으로 정확히 추출되지 않는 경우를 대비하여 `manual_price`를 함께 입력하였다.\n",
    "\n",
    "이 단계가 성공하면 최종 앱에서는 사용자가 링크, 실제 가격, 원하는 언어를 입력하면 결과를 바로 확인할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 69,
   "id": "fb438590-5d22-4460-9305-464a09c3dae5",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "===== 상품 정보 =====\n",
      "{'제목': '아이폰 13 화이트 128GB | 디지털기기 중고거래', '가격': '350,000원', '가격_numeric': 350000, '상세설명': '화이트 색상의 아이폰 13 128GB 모델입니다 배터리 성능 78%에 찍힘, 깨짐 하나도 없고 티도 많이 안나는 생활기스만 있어요', '링크': 'https://www.daangn.com/kr/buy-sell/%EC%95%84%EC%9D%B4%ED%8F%B0-13-%ED%99%94%EC%9D%B4%ED%8A%B8-128gb-z1s7m3v1ybq9/'}\n",
      "\n",
      "===== 가격 판단 결과 =====\n",
      "{'제목': '아이폰 13 화이트 128GB | 디지털기기 중고거래', '세부카테고리': '스마트폰', '실제가격': 350000, '예측적정가격': 446517, '가격판단': '저렴'}\n",
      "\n",
      "===== AI 다국어 설명 =====\n",
      "### 1. Product Summary\n",
      "这是一款白色的iPhone 13，存储容量为128GB。它的电池性能为78%，外观上没有任何划痕或破损，只有一些轻微的使用痕迹。\n",
      "\n",
      "### 2. Price Check\n",
      "这款手机的售价为350,000韩元，相比参考价格446,517韩元来说，价格是比较便宜的。不过请注意，参考价格是基于收集的数据预测的，并不一定代表绝对的市场价格。\n",
      "\n",
      "### 3. Things to Check Before Buying\n",
      "在购买之前，您需要确认以下几点：\n",
      "1. 手机的电池性能是否符合您的需求。\n",
      "2. 是否有其他隐藏的损坏或问题。\n",
      "3. 卖家的信誉和评价。\n",
      "4. 是否包含原包装和配件。\n",
      "\n",
      "### 4. Message to Seller\n",
      "您好！请问这款iPhone 13是否可以议价？谢谢！\n",
      "\n",
      "### 5. Simple Korean Words\n",
      "- 아이폰 (iPhone): 苹果公司生产的智能手机品牌。\n",
      "- 화이트 (白色): 一种颜色，指的是纯净的白色。\n",
      "- 배터리 (电池): 供电的装置，用于手机等电子设备。\n",
      "- 성능 (性能): 设备的工作能力或效率。\n",
      "- 가격 (价格): 商品的出售金额。\n"
     ]
    }
   ],
   "source": [
    "test_url = \"https://www.daangn.com/kr/buy-sell/%EC%95%84%EC%9D%B4%ED%8F%B0-13-%ED%99%94%EC%9D%B4%ED%8A%B8-128gb-z1s7m3v1ybq9/\"\n",
    "\n",
    "result = analyze_used_item_link(\n",
    "    url=test_url,\n",
    "    language=\"Chinese\",\n",
    "    manual_price=350000\n",
    ")\n",
    "\n",
    "print(\"===== 상품 정보 =====\")\n",
    "print(result[\"상품정보\"])\n",
    "\n",
    "print(\"\\n===== 가격 판단 결과 =====\")\n",
    "print(result[\"가격판단결과\"])\n",
    "\n",
    "print(\"\\n===== AI 다국어 설명 =====\")\n",
    "print(result[\"AI설명\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e66fdb41-04bd-49f5-a822-32c9f76607fa",
   "metadata": {},
   "source": [
    "## 통합 결과\n",
    "\n",
    "본 프로젝트는 중고거래 게시글 링크를 입력하면 상품 정보를 추출하고, 머신러닝 모델을 활용하여 수집 데이터 기준 참고 가격을 예측한다.\n",
    "\n",
    "이후 실제 판매 가격과 예측 참고 가격을 비교하여 가격이 저렴한지, 적정한지, 비싼지 판단한다.  \n",
    "또한 LLM을 활용하여 상품 상세설명과 가격 판단 결과를 영어, 일본어, 중국어 등으로 제공한다.\n",
    "\n",
    "이를 통해 한국어 중고거래 게시글을 이해하기 어려운 유학생도 상품 정보와 가격 상태를 쉽게 파악할 수 있다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e5438fb6-fbc4-4e5e-b098-2431c3c896ba",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python [conda env:base] *",
   "language": "python",
   "name": "conda-base-py"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.9"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
