# [데이터 수집] BeautifulSoup, Selenium

## 정적 웹페이지, 동적 웹페이지

---

**정적 웹페이지**

- 웹 서버에 이미 저장된 기본 html 전송

- 데이터 자체의 변화 없는 한 고정 데이터 전달

- Ex) 홈페이지 회사 소개

**동적 웹페이지**

- 요청 정보 처리 후 제작된 html 페이지 전송

- 사용자가 요청하는 상황 등에 따라 다른 데이터 전달

- Ex) 뉴스 댓글, 상품 검색

|  | **정적 웹페이지** | **동적 웹페이지** |  |
| --- | --- | --- | --- |
| **BeautifulSoup** | O (페이지 내의 고정 html만 전달받음) | X |  |
| **Selenium** | O | O | 원래 테스트 엔지니어링을 돕는 프로그램 |

## BeautifulSoup

---

- 간단하게 HTML, XML에서 정보를 추출할 수 있는 라이브러리

- HTML, XMl의 트리 구조를 탐색, 검색, 변경 가능

- 다운로드 기능은 포함하지 않음

- 다양한 파서(parser)를 선택하여 이용 가능

| **파서 (parser)** | **선언** | **장점** | **단점** |
| --- | --- | --- | --- |
| html.parser | BeautifulSoup(markup, ‘html.parser’) | 설치 필요 없음 적절한 속도 |  |
| lxml HTML parser | BeautifulSoup(markup, ‘lxml’) | 매우 빠름 | lxml 추가 설치 필요 |
| lxml XML parser | BeautifulSoup(markup, ‘xml’) | 매우 빠름 유일한 xml parser | lxml 추가 설치 필요 |
|  |  |  |  |
| html5lib | BeautifulSoup(markup, ‘html5lib’) | 웹 브라우저와 같은 방식으로 파싱 유용한 HTML5 생성 | html5lib 추가 설치 필요 매우 느림 |

```javascript
# BeautifulSoup 설치
!pip install beautifulsoup4
```

## HTML 파싱 (Parsing)

---

### 웹페이지 예제

```javascript
%%writefile example.html
<!DOCTYPE html>
<html>
  <head>
    <title>Page Title</title>
  </head>
  <body>
    <h1>Heading</h1>
    <p>Paragraph</p>
    <div>
      <a href="www.google.com">google</a>
    </div>
    <div class="class1">
      <p>a</p>
      <a href="www.naver.com">naver</a>
      <p>b</p>
      <p>c</p>
    </div>
    <div id = "id1">
      Example Page
      <p>g</p>
    </div>
  </body>
</html>
```

### HTML parser

```javascript
from bs4 import BeautifulSoup
import urllib.request
with open("example.html") as fp:
  soup = BeautifulSoup(fp, 'html.parser')

print(soup.prettify())
```

### HTML 태그 파싱

```javascript
soup.title # title 태그만
soup.title.name # title 태그의 이름만
soup.title.string # title 태그의 문자만
soup.title.parent # title 태그의 부모 태그

soup.h1 # h1 태그
soup.p # p 태그
soup.div # div태그, div에 포함된 태그들까지 함께 불러옴
soup.a # a태그
```

## HTML 태그 검색

---

### find()

- 해당 조건에 맞는 하나의 태그를 가져옴

```javascript
soup.find('div') # 첫번째 div
soup.find('a').get('href') # a태그의 'href' 속성 값 
soup.find('a').get_text() # a태그의 텍스트만
```

### find_all()

- 해당 조건에 맞는 모든 태그를 가져옴

- 리스트 형태로 반환

```javascript
soup.find_all('div') # div가 사용된 모든 태그
soup.find_all('div', {'id':'id1'} # attribute 중 id가 id1인 div태그

site_names = soup.find_all('a') # a태그가 사용된 모든 태그
for name in site_names: 
  print(name.get('href')) # a태그의 href만 출력

site_names = soup.find_all('a') # a태그가 사용된 모든 태그
for name in site_names:
  print(name.get_text()) # a태그의 text만 출
```

### select()

- CSS 선택자와 같은 형식으로 선택 가능

```javascript
soup.select('div#id1') # id는 #으로 접근
soup.select('div.class1') # class는 .으로 접근

soup.select('div.class1 a') # class가 class1인 태그의 a태그
soup.select('div.class1 > a') # 위와 같음
```

## 웹페이지 콘텐츠 가져오기

---

### 웹페이지 예제

```javascript
%%writefile anthem.html
<!DOCTYPE html>
<html>
<head>
</head>
<body>
  <div>
    <p id="title">애국가</p>
    <p id="content">
      동해물과 백두산이 마르고 닳도록 하느님이 보우하사 우리나라 만세. <br/>
      무궁화 삼천리 화려 강산 대한 사람, 대한으로 길이 보전하세, <br/>
    </p>
    <p id="content">
      남산 위에 저 소나무, 철갑을 두른 듯 바람 서리 불변함은 우리 기상일세. <br/>
      무궁화 삼천리 화려 강산 대한 사람, 대한으로 길이 보전하세. <br/>
    </p>
    <p id="content">
      가을 하늘 공활한데 높고 구름 없이 밝은 달은 우리 가슴 일편단심일세. <br/>
      무궁화 삼천리 화려 강산 대한 사람, 대한으로 길이 보전하세, <br/>
    </p>
    <p id="content">
      이 기상과 이 맘으로 충성을 다하여 괴로우나 즐거우나 나라 사랑하세. <br/>
      무궁화 삼천리 화려 강산 대한 사람, 대한으로 길이 보전하세, <br/>
    </p>
  </div>
</body>
</html>
```

```javascript
with open("anthem.html") as fp:
  soup = BeautifulSoup(fp, 'html.parser')

soup
```

### 내용 추출

```javascript
# 애국가의 가사만 가져오기
title = soup.find('p', {'id':'title'}) # id가 title인 p태그만
contents = soup.find_all('p', {'id':'content'}) # id가 content인 p태그만

print(title.get_text()) # title의 text 출력 
for content in contents:
  print(content.get_text()) # content의 text 출력
```

### 인터넷 웹페이지 가져오기

- F12 or 우클릭 검사 → html 코드 확인 가능

- 검사창 왼쪽 위 화살표 그림을 클릭하여 원하는 요소의 코드 선택 가능

- 원하는 코드 우클릭 → copy → selector를 이용하면 css selector 사용 가능

- 복사해온 css selector에서  :nth-child() → 특정 대상을 지정, 웹 스크래핑의 경우 삭제 한다.

![Image](https://upload.cafenono.com/image/slashpagePost/20250201/171809_rtJanVkSe5VCTjIDzr?q=80&s=1280x180&t=outside&f=webp)

![Image](https://upload.cafenono.com/image/slashpagePost/20250201/171819_1QpTNsb6jBAPrWF7RS?q=80&s=1280x180&t=outside&f=webp)

```javascript
url = "http://suanlab.com" # 가져오고 싶은 웹페이지 주소
html = urllib.request.urlopen(url).read() # 웹페이지 읽어오기
soup = BeautifulSoup(html, 'html.parser') # html parser 이용
soup
```

```javascript
labels = soup.find_all(['label']) # label 태그 모두
for label in labels:
  print(label.get_text()) # label 태그의 text만
```

```javascript
labels = soup.select('#wrapper > section > div > div > div > div > div > label') # CSS selector 이용
for label in labels:
  print(label.get_text())
```

## BeautifulSoup으로 gmarket 스크래핑

---

```javascript
#1. 필요한 라이브러리 import 하기
import requests
from bs4 import BeautifulSoup
import re
import pandas as pd
```

```javascript
#2. 링크를 request.get으로 가져오기 (header가 필요한 경우 headers 입력)
res = requests.get('https://www.gmarket.co.kr/n/best?viewType=G&groupCode=G07') # 식품 카테고리

#header 입력
headers = {
    "User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36"
}
```

```javascript
#3. BeautifulSoup을 이용해 파싱하기
soup = BeautifulSoup(res.content, 'html.parser')
```

```javascript
#4. 상품명 크롤링하여 리스트로 저장하기 (20점)
item_list = soup.select('a.itemname')

# 상품의 이름만 title_list에 저장
title_list = list()
for item in item_list:
  title_list.append(item.get_text())
```

```javascript
#5. 정가 정보 크롤링하여 리스트로 저장하기 (10점)
item_list = soup.select('div.o-price')

# 상품의 정가 정보만 original_price_list에 저장
original_price_list = list()

for item in item_list:
  original_price_list.append(item.get_text())
```

```javascript
#6. 할인가 정보 크롤링하여 리스트로 저장하기 (10점)
item_list = soup.select('div.s-price')

# 상품의 할인가 정보만 sale_price_list에 저장
sale_price_list = list()

for item in item_list:
  sale_price_list.append(item.get_text())
```

```javascript
#7. 적절히 데이터 전처리를 하여 DataFrame 객체로 만들고 Excel 파일로 저장하기 (30점)
#이때, DataFrame의 열은 "상품명, 정가, 판매가"로 구성되어야 함.

# 정가, 판매가 서식
def extract_number(x):
  price = re.findall("\d.*원", x)
  try:
    price_num = price[0].replace("원", "")
    try:
      price_num = int(price_num.replace(",", ""))
    except:
      price_num = int(price_num)
  except:
    pass
  return price_num if price else None

# 정가 열이 na면 판매가를 대입
def fill_na_with_sales(row):
    if pd.isna(row['정가']):
        return row['판매가']
    else:
        return row['정가']

# gmarket_food_df에 상품명, 정가, 판매가의 정보를 DataFrame으로 저장
gmarket_food_df = pd.DataFrame([title_list, original_price_list, sale_price_list]).T
gmarket_food_df.columns = ['상품명', '정가', '판매가']

# extract_numer -> 정가, 판매가 숫자만 남기기
gmarket_food_df['정가'] = gmarket_food_df['정가'].apply(extract_number)
gmarket_food_df['판매가'] = gmarket_food_df['판매가'].apply(extract_number)

# fill_na_with_sale -> 정가의 빈값은 판매가로 채우기
gmarket_food_df['정가'] = gmarket_food_df.apply(fill_na_with_sales, axis=1)
gmarket_food_df['정가'] = gmarket_food_df['정가'].apply(int)

# 엑셀파일로 저장
gmarket_food_df.to_excel('gmarket_best_item.xlsx')
```

## selenium

---

### Seleniuum이란?

- 웹 브라우저를 원격 조작할 때 사용하는 도구

- 일반적으로 웹 애플리케이션 테스트를 자동화할 때 사용

### 웹 스크래핑에서 Selenium을 사용하는 이유

- 자바스크립트를 많이 사용하는 웹 사이트는 웹 브라우저를 사용하지 않을 경우 제대로 동작하지 않음

- requests 모듈 및 BeautifulSoup으로 검색이 불가능하다.

- 이를 해결하기 위해 웹 브라우저를 원격으로 조작하는 방식을 사용

### Selenium 기능

- 자동으로 URL열고 클릭

- 스크롤

- 문자 입력

- 화면 캡쳐해서 이미지 저장

- HTML의 특정 부분 추출

### Selenium의 장점

- 다양한 웹 브라우저에 대응한다.

- ex) 구글 크롬, 파이어폭스, 인터넷 익스플로러, 오페라 등

## chrome webdriver 설치

---

### jupyter notebook

1. **크롬 브라우저 버전 확인**

chrome://settings/help

![Image](https://upload.cafenono.com/image/slashpagePost/20250201/171925_5HQbMChGtmskBaQ39n?q=80&s=1280x180&t=outside&f=webp)

1. **chrome 버전에 맞춰 webdriver를 다운로드***

[ChromeDriver - WebDriver for Chrome - Downloads](https://sites.google.com/chromium.org/driver/downloads?authuser=0)

![Image](https://upload.cafenono.com/image/slashpagePost/20250201/171948_p5U9x8fuuLrGJ3Z3f1?q=80&s=1280x180&t=outside&f=webp)

![Image](https://upload.cafenono.com/image/slashpagePost/20250201/171955_ObSBwg0BZKk9o4ENZC?q=80&s=1280x180&t=outside&f=webp)

1. **Windows**

![Image](https://upload.cafenono.com/image/slashpagePost/20250201/172030_KQtjtmkk0GDh8HxOgz?q=80&s=1280x180&t=outside&f=webp)

- 압출파일 압출 해제

- Chromedriver.exe 파일을 로컬 디스크 (C:) 내의 Windows 폴더에 넣기

### Google Colab

```javascript
!pip install Selenium
!apt-get update
!apt-get install chromium-chromedriver
!cp /usr/lib/chromium-browser/chromedriver /usr/bin

import sys
sys.path.insert(0, '/usr/lib/chromium-browser/chromedriver')
```

### 웹 드라이버 설정 및 객체 생성

```javascript
#크롬 드라이버 자동 업데이트
from webdriver_manager.chrome import ChromeDriverManager

# ChromeDriverManager를 사용하여 크롬 드라이버의 최신버전 자동 설치, service 변수에 할당
service = Service(executable_path=ChromeDriverManager().install()) 
```

```javascript
# option 설정할 경우
from selenium import webdriver

# 다양한 chrome_options
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--headless') # 웹 브라우저를 화면에 나타내지 않음
chrome_options.add_argument('--no-sandbox') # chrome의 보안 기능 중 하나인 snadbox 비활성화
chrome_options.add_argument('--disable-dev-shm-usage') # /dv/shm 사용 안함 - 리눅스 시스템에서 메모리 사용 최적화

chrome_options.add_experimental_option('detach',True) # 브라우저 꺼짐 방지 - 브라우저 세션 유지. 브라우저가 닫혔다 다시 열려도 작업 상태 유지된다.
chrome_options.add_experimental_option('excludeSwitches', ['enable-logging']) # 불필요한 에러 메세지 없애기 - 크롬 브라우저가 로깅 관련 스위치를 사용하지 않도록 하여 불필요한 에러 메시지가 표시되지 않도록 한다.

driver = webdriver.Chrome(service=service, options=chrome_options)
```

```javascript
# option 설정 안할 경우
driver = webgdriver.Chrome()
```

## selenium 주요 코드

---

### get_screenshot_as_file

- 웹페이지 스크린샷 후 저장

```javascript
driver.get('http://suanlab.com/') # 웹 페이지 지정
driver.get_screenshot_as_file('suanlab.png') # 웹페이지 스크린샷 후 저장

# google colab의 경우
from google.colab import files
files.download('suanlab.png') # google colab에서 파일 다운로드
```

### find_element() / find_elements()

- find_element(): 조건에 맞는 태그를 하나 가져옴

- find_elements(): 조건에 맞는 태그를 모두 가져옴

### By문

- find_element(), find_elements()에서 찾을 태그의 조건

```javascript
from selenium.webdriver.common.by import By

# By에 해당하는 메소드로 키워드에 해당하는 것 반환 (한 개만)
driver.find_element(By문, 키워드)

# By에 해당하는 메소드로 키워드에 해당하는 것 반환 (모두, 여러 개)
driver.find_elements(By문, 키워드)
```

### By.TAG_NAME

- 태그 이름이 키워드와 같은 것

```javascript
for label in driver.find_elements(By.TAG_NAME, 'label'): # 태그 이름이 label인 것을 선택
  print(label.tex)
```

### By.CSS_SELECTOR

- css selector가 키워드와 같은 것

```javascript
for label in driver.find_elements(By.CSS_SELECTOR, '#wrapper > section > div > div > div > div > div > label'):
  print(label.text)
```

### By.XPATH

- xpath가 키워드와 같은 것

```javascript
for label in driver.find_elements(By.XPATH, '//*[@id="wrapper"]/section/div/div/div/div/div/label'):
  print(label.text)
```

### By.CLASS_NAME

- class가 키워드와 같은 것

```javascript
toggle_class = driver.find_element(By.CSS_SELECTOR, '#wrapper > section > div > div > div > div')
for label in toggle_class.find_elements(By.CLASS_NAME, 'toggle'):
  print(label.text)
```

### Input 태그

- 데이터를 입력 받을 수 있는 태그

- Ex) 검색창, 아이디 입력 창 등

1. input 요소를 찾아서 변수에 할당

2. 변수명.send_keys(입력)을 통해 입력

3. 변수명.send_keys(keys.RETURN)을 통해 입력한 것 엔터로 실행

```javascript
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.chrome.service import Service

# ChromeDriver의 경로를 지정하고, WebDriver 인스턴스를 생성합니다.
driver = webdriver.Chrome()

# 지마켓의 메인 페이지를 엽니다.
driver.get('https://www.gmarket.co.kr')

# 검색어를 입력할 수 있는 input 요소를 찾습니다.
search_box = driver.find_element(By.NAME, 'keyword')

# 검색어를 입력하고, Enter 키를 눌러 검색을 실행합니다.
search_box.send_keys('손풍기')
search_box.send_keys(Keys.RETURN)

# 작업이 끝나면 WebDriver를 종료하여 브라우저 창을 닫습니다.
driver.quit()
```

## selenium gmarket 실습 코드

---

```javascript
#1. 필요한 라이브러리 import 하기 (10점)
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.chrome.service import Service
import pandas as pd
import re
```

```javascript
#2. 본인이 관심 있는 키워드에 대해서 크롤링을 수행하고 상품명, 가격, 별점 정보 저장하기 (40점)

title_list = list() # 상품명
price_list = list() # 가격
star_list = list() # 별점 정보

# ChromeDriver의 경로 지정, WebDriver 인스턴스 생성
driver = webdriver.Chrome()

# 지마켓의 메인 페이지 열기
driver.get('https://www.gmarket.co.kr')

# 검색어 입력할 수 있는 input 요소
search_box = driver.find_element(By.NAME, 'keyword')

# 검색어 입력, Enter 키 눌러 검색 실행
search_box.send_keys('제습기')
search_box.send_keys(Keys.RETURN)

# 결과 페이지가 로드될 때까지 잠시 대기
driver.implicitly_wait(3)

# 정보를 담은 박스를 선택
search_results = driver.find_elements(By.CSS_SELECTOR, 'div.box__information')

for result in search_results:
    # 만족도가 없을 수도 있기에 try except문을 활용
    try:
        title_element = result.find_element(By.CSS_SELECTOR, 'span.text__item')
        price_element = result.find_element(By.CSS_SELECTOR, 'div.box__price-seller > strong')
        star_element = result.find_element(By.CSS_SELECTOR, 'span.image__awards-points > span')
        title_list.append(title_element.text)
        price_list.append(price_element.text)
        star_list.append(star_element.text)
    except:
        pass

# 작업이 끝나면 WebDriver를 종료하여 브라우저창 닫기
driver.quit()
```

```javascript
#3. 적절히 데이터 전처리를 하여 DataFrame 객체로 만들기
#이때, DataFrame의 열은 "상품명, 판매가, 만족도"로 구성되어야 함.

gmarket_df = pd.DataFrame([title_list, price_list, star_list]).T
gmarket_df.columns = ['상품명', '판매가', '만족도']
```

```javascript
# 4. 적절히 전처리 후 excel 파일로 저장하기
# 전처리 중 데이터 손실 대비 백업
gmarket_dehumidifier_df = gmarket_df.copy()

# 데이터 정보 확인
gmarket_dehumidifier_df.info()

# 결측치 확인
gmarket_dehumidifier_df.isna().sum()

# 판매가의 쉽표를 제거하고 object형 데이터를 int형으로 변환
def extract_comma(x):
    price = int(x.replace(",", ""))
    return price

# 판매가 column 데이터 -> extract_comma 함수로 콤마 제거 후 int형으로 
gmarket_dehumidifier_df['판매가'] = gmarket_dehumidifier_df['판매가'].apply(extract_comma)

# 결측치는 존재하지 않지만, 만족도 데이터 중 빈칸이 존재하는 것을 확인할 수 있다. 
def extract_stars(x): #
    ext = re.findall("\d+%", x)

    try: # 만족도가 존재할 경우 숫자만 남기고 object형을 int형으로 변환
        stars = int(ext[0].replace('%', ""))
    except:
        pass
    
    # 만족도가 존재하지 않을 경우 -1 반환    
    return stars if ext else -1

# 만족도 column 데이터 -> extract_Starts 함수로 숫자만 남기고 int형으로
gmarket_dehumidifier_df['만족도'] = gmarket_dehumidifier_df['만족도'].apply(extract_stars)

# -1(결측치)로 변환한 빈칸들을 dropna를 이용하여 제거
gmarket_dehumidifier_df = gmarket_dehumidifier_df[gmarket_dehumidifier_df['만족도'] != -1].reset_index(drop=True)

# Dtype 변경 확인
gmarket_dehumidifier_df.info()

# 변경된 데이터 확인
gmarket_dehumidifier_df.head()

# 데이터 전체 정보 확인
gmarket_dehumidifier_df.describe()

# 한글 폰트 사용
import matplotlib.font_manager as fm

font_list = [font.name for font in fm.fontManager.ttflist]
font_list

plt.rcParams['font.family'] = 'S-Core Dream'

# 히스토그램으로 데이터 분포 확인
import matplotlib.pyplot as plt
gmarket_dehumidifier_df.hist() # 판매가 이상치 확인

# seaborn으로 boxplot 그려서 확인하기
import seaborn as sns

# 그래프 공간 할당
plt.figure(figsize = (10, 6))

# 판매가의 boxplot 그리기
sns.boxplot(x='판매가', data = gmarket_dehumidifier_df)
plt.title('판매가 boxplot')
plt.xlabel('판매가')
plt.ylabel('가격')
plt.show()

# 만족도의 boxplot 그리기
sns.boxplot(x='만족도', data = gmarket_dehumidifier_df)
plt.title('만족도 boxplot')
plt.xlabel('만족도')
plt.ylabel('백분윻')

import pandas as pd

# 각 칼럼의 outlier들을 제거 하는 함수
def outlier_remove(data):
    s_data = pd.Series(data)
    level_1q = s_data.quantile(0.25)
    level_3q = s_data.quantile(0.75)
    IQR = level_3q - level_1q
    rev_range = 1.5
    dff = s_data[(s_data <= level_3q + (rev_range * IQR)) & (s_data >= level_1q - (rev_range * IQR))]
    dff = dff.reset_index(drop=True)
    return dff

# 판매가와 만족도의 이상치 데이터들을 NaN, 결과를 새로운 변수에 할당
gmarket_dehumidifier_df['판매가'] = outlier_remove(gmarket_dehumidifier_df['판매가'])
gmarket_dehumidifier_df['만족도'] = outlier_remove(gmarket_dehumidifier_df['만족도'])

# 이상치들이 삭제되어 결측치가 있는 행을 제거
gmarket_dehumidifier_df.dropna(inplace=True)

# 이상치 제거 후 94개 -> 88개
gmarket_dehumidifier_df.info()

# 결과 출력

# 히스토그램
gmarket_dehumidifier_df.hist()

# 그래프 공간 할당
plt.figure(figsize = (10, 6))

# 판매가의 boxplot 그리기
sns.boxplot(x='판매가', data = gmarket_dehumidifier_df)
plt.title('판매가 boxplot')
plt.xlabel('판매가')
plt.ylabel('가격')
plt.show()

# 만족도의 boxplot 그리기
sns.boxplot(x='만족도', data = gmarket_dehumidifier_df)
plt.title('만족도 boxplot')
plt.xlabel('만족도')
plt.ylabel('백분윻')

# excel로 저장
gmarket_dehumidifier_df.to_excel('gmarket_dehumidifier_star.xlsx')
```

Tip! 결측치 처리 방법들 중 -1로 바꾸는 것과 Nan으로 바꾸는 것의 차이는 무엇일까?

-1로 변경 후 해당 행을 삭제하면 판매가, 가격의 Dtype은 int이다. dropna를 이용하여 결측치를 제거하는 방법을 사용하면 판매가, 가격의 Dtype은 float64가 된다.

For the site tree, see the [root Markdown](https://slashpage.com/yerim-devnote.md).
