Python 자동화 마스터 3편: 웹 스크래핑 기초
Python Automation Master Part 3: Web Scraping Basics
서론: 웹 스크래핑이란?
웹 스크래핑(Web Scraping)은 웹사이트에서 필요한 데이터를 자동으로 추출하는 기술입니다. 웹 크롤링(Web Crawling)이라고도 불리며, 방대한 양의 웹 데이터를 효율적으로 수집할 수 있어 데이터 분석, 가격 모니터링, 뉴스 수집 등 다양한 분야에서 활용됩니다.
이번 편에서는 Python을 사용한 웹 스크래핑의 기초를 배웁니다. requests 라이브러리로 웹 페이지를 가져오고, BeautifulSoup으로 HTML을 파싱하여 원하는 데이터를 추출하는 방법을 단계별로 알아보겠습니다.
1. 법적/윤리적 고려사항
웹 스크래핑을 시작하기 전에 반드시 알아야 할 법적, 윤리적 고려사항이 있습니다.
1.1 robots.txt 확인
robots.txt는 웹사이트 루트 디렉토리에 위치한 파일로, 웹 크롤러가 접근할 수 있는 페이지와 접근하면 안 되는 페이지를 명시합니다.
# robots.txt 예시 (https://example.com/robots.txt)
User-agent: *
Disallow: /private/
Disallow: /admin/
Allow: /public/
Crawl-delay: 10
- User-agent: 규칙이 적용될 크롤러를 지정합니다. *는 모든 크롤러를 의미합니다.
- Disallow: 크롤링이 금지된 경로를 지정합니다.
- Allow: 크롤링이 허용된 경로를 지정합니다.
- Crawl-delay: 요청 사이의 대기 시간(초)을 지정합니다.
1.2 웹 스크래핑 윤리 수칙
- robots.txt 존중: 웹사이트의 robots.txt 규칙을 반드시 확인하고 준수합니다.
- 서버 부하 최소화: 요청 사이에 적절한 딜레이를 두어 서버에 과부하를 주지 않습니다.
- 이용약관 확인: 웹사이트의 이용약관에서 스크래핑 관련 조항을 확인합니다.
- 개인정보 보호: 개인정보를 무단으로 수집하지 않습니다.
- 저작권 존중: 수집한 데이터의 저작권을 존중하고 적절히 사용합니다.
1.3 Python으로 robots.txt 확인하기
from urllib.robotparser import RobotFileParser
def check_robots_txt(url, user_agent='*'):
"""robots.txt를 확인하여 크롤링 가능 여부를 반환합니다."""
# robots.txt URL 생성
from urllib.parse import urlparse
parsed = urlparse(url)
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
# RobotFileParser 설정
rp = RobotFileParser()
rp.set_url(robots_url)
rp.read()
# 해당 URL의 크롤링 가능 여부 확인
can_fetch = rp.can_fetch(user_agent, url)
crawl_delay = rp.crawl_delay(user_agent)
return {
'can_fetch': can_fetch,
'crawl_delay': crawl_delay
}
# 사용 예시
result = check_robots_txt('https://www.google.com/search')
print(f"크롤링 가능: {result['can_fetch']}")
print(f"크롤링 딜레이: {result['crawl_delay']}초")
2. requests 라이브러리
requests는 Python에서 HTTP 요청을 보내는 가장 인기 있는 라이브러리입니다. 간단하고 직관적인 API를 제공하여 웹 페이지를 쉽게 가져올 수 있습니다.
2.1 설치
# pip를 사용하여 설치
pip install requests
2.2 HTTP 메서드
HTTP 프로토콜에서 사용되는 주요 메서드와 requests에서의 사용법을 알아봅니다.
GET 요청
가장 일반적인 요청 방식으로, 서버에서 데이터를 가져올 때 사용합니다.
import requests
# 기본 GET 요청
response = requests.get('https://httpbin.org/get')
print(response.text)
# 쿼리 파라미터와 함께 GET 요청
params = {
'search': 'python',
'page': 1,
'limit': 10
}
response = requests.get('https://httpbin.org/get', params=params)
print(response.url) # https://httpbin.org/get?search=python&page=1&limit=10
POST 요청
서버에 데이터를 전송할 때 사용합니다. 폼 데이터나 JSON 데이터를 보낼 수 있습니다.
import requests
# 폼 데이터 전송
form_data = {
'username': 'user123',
'password': 'pass456'
}
response = requests.post('https://httpbin.org/post', data=form_data)
print(response.json())
# JSON 데이터 전송
json_data = {
'name': '홍길동',
'email': 'hong@example.com'
}
response = requests.post('https://httpbin.org/post', json=json_data)
print(response.json())
헤더 설정
User-Agent 등 HTTP 헤더를 설정하여 요청을 커스터마이즈할 수 있습니다.
import requests
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Referer': 'https://www.google.com/'
}
response = requests.get('https://httpbin.org/headers', headers=headers)
print(response.json())
2.3 응답 처리
requests는 다양한 형태로 응답을 처리할 수 있는 기능을 제공합니다.
import requests
response = requests.get('https://httpbin.org/get')
# 상태 코드 확인
print(f"상태 코드: {response.status_code}") # 200
# 성공 여부 확인
if response.ok: # status_code가 200-299 범위인 경우 True
print("요청 성공!")
# 응답 본문 (텍스트)
print(response.text)
# 응답 본문 (JSON)
data = response.json()
print(data)
# 응답 본문 (바이너리) - 이미지 등
content = response.content
# 응답 헤더
print(response.headers)
print(response.headers['Content-Type'])
# 인코딩
print(response.encoding) # UTF-8
response.encoding = 'utf-8' # 인코딩 지정
HTTP 상태 코드
| 상태 코드 | 의미 | 설명 |
|---|---|---|
| 200 | OK | 요청 성공 |
| 301 | Moved Permanently | 영구적으로 이동됨 |
| 302 | Found | 임시 리다이렉트 |
| 400 | Bad Request | 잘못된 요청 |
| 403 | Forbidden | 접근 권한 없음 |
| 404 | Not Found | 페이지를 찾을 수 없음 |
| 500 | Internal Server Error | 서버 내부 오류 |
2.4 에러 처리와 타임아웃
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
def safe_request(url, timeout=10):
"""안전한 HTTP 요청을 수행합니다."""
try:
response = requests.get(url, timeout=timeout)
response.raise_for_status() # 4xx, 5xx 에러 시 예외 발생
return response
except Timeout:
print(f"타임아웃 발생: {url}")
except HTTPError as e:
print(f"HTTP 에러: {e.response.status_code}")
except RequestException as e:
print(f"요청 실패: {e}")
return None
# 사용 예시
response = safe_request('https://httpbin.org/get')
if response:
print(response.text)
3. BeautifulSoup으로 HTML 파싱
BeautifulSoup은 HTML과 XML 문서를 파싱하기 위한 Python 라이브러리입니다. 복잡한 HTML 구조에서 원하는 데이터를 쉽게 추출할 수 있습니다.
3.1 설치
# BeautifulSoup과 lxml 파서 설치
pip install beautifulsoup4 lxml
3.2 기본 사용법
from bs4 import BeautifulSoup
import requests
# 웹 페이지 가져오기
url = 'https://example.com'
response = requests.get(url)
# BeautifulSoup 객체 생성
soup = BeautifulSoup(response.text, 'lxml') # 또는 'html.parser'
# HTML 구조 확인 (예쁘게 출력)
print(soup.prettify())
3.3 태그로 요소 찾기
from bs4 import BeautifulSoup
html = """
<html>
<head><title>테스트 페이지</title></head>
<body>
<h1>메인 제목</h1>
<p>첫 번째 단락</p>
<p>두 번째 단락</p>
<a href="https://example.com">링크1</a>
<a href="https://google.com">링크2</a>
</body>
</html>
"""
soup = BeautifulSoup(html, 'lxml')
# 첫 번째 태그 찾기
title = soup.find('title')
print(title.text) # 테스트 페이지
h1 = soup.find('h1')
print(h1.text) # 메인 제목
# 모든 태그 찾기
paragraphs = soup.find_all('p')
for p in paragraphs:
print(p.text)
links = soup.find_all('a')
for link in links:
print(link.text, link['href'])
3.4 클래스와 ID로 요소 찾기
from bs4 import BeautifulSoup
html = """
<html>
<body>
<div id="header">헤더 영역</div>
<div class="content">
<p class="intro">소개 글</p>
<p class="main-text">본문 내용</p>
</div>
<div class="sidebar">사이드바</div>
<ul class="menu">
<li class="menu-item active">홈</li>
<li class="menu-item">소개</li>
<li class="menu-item">연락처</li>
</ul>
</body>
</html>
"""
soup = BeautifulSoup(html, 'lxml')
# ID로 찾기
header = soup.find(id='header')
print(header.text) # 헤더 영역
# 클래스로 찾기
content = soup.find(class_='content')
print(content.text)
# 클래스명으로 여러 요소 찾기
menu_items = soup.find_all(class_='menu-item')
for item in menu_items:
print(item.text)
# 복합 조건으로 찾기
active_item = soup.find('li', class_='active')
print(active_item.text) # 홈
# CSS 선택자 사용 (select)
main_text = soup.select_one('.content .main-text')
print(main_text.text) # 본문 내용
all_menu_items = soup.select('ul.menu li')
for item in all_menu_items:
print(item.text)
3.5 속성 접근과 텍스트 추출
from bs4 import BeautifulSoup
html = """
<html>
<body>
<a href="https://example.com" title="예제 링크" data-id="123">
<span>링크</span> 텍스트
</a>
<img src="image.jpg" alt="이미지 설명">
<div class="article">
<h2>제목</h2>
<p>첫 번째 단락</p>
<p>두 번째 단락</p>
</div>
</body>
</html>
"""
soup = BeautifulSoup(html, 'lxml')
# 속성 접근
link = soup.find('a')
print(link['href']) # https://example.com
print(link['title']) # 예제 링크
print(link.get('data-id')) # 123
print(link.get('class')) # None (없으면 None 반환)
# 모든 속성 가져오기
print(link.attrs) # {'href': 'https://example.com', 'title': '예제 링크', 'data-id': '123'}
# 이미지 태그 속성
img = soup.find('img')
print(img['src']) # image.jpg
print(img['alt']) # 이미지 설명
# 텍스트 추출
print(link.text) # 링크 텍스트 (모든 자식 텍스트 포함)
print(link.string) # None (여러 자식이 있으면 None)
print(link.get_text()) # 링크 텍스트
# 공백 제거
print(link.get_text(strip=True)) # 링크 텍스트
# 구분자로 연결
article = soup.find(class_='article')
print(article.get_text(separator=' | ', strip=True))
# 제목 | 첫 번째 단락 | 두 번째 단락
3.6 CSS 선택자 고급 사용법
from bs4 import BeautifulSoup
html = """
<html>
<body>
<table id="data-table">
<tr><th>이름</th><th>나이</th><th>직업</th></tr>
<tr><td>홍길동</td><td>30</td><td>개발자</td></tr>
<tr><td>김영희</td><td>25</td><td>디자이너</td></tr>
<tr><td>박철수</td><td>35</td><td>매니저</td></tr>
</table>
<div class="products">
<div class="product" data-price="10000">
<span class="name">상품 A</span>
</div>
<div class="product" data-price="20000">
<span class="name">상품 B</span>
</div>
</div>
</body>
</html>
"""
soup = BeautifulSoup(html, 'lxml')
# 자손 선택자 (공백)
cells = soup.select('#data-table td')
for cell in cells:
print(cell.text)
# 직계 자식 선택자 (>)
rows = soup.select('#data-table > tr')
# 속성 선택자
products = soup.select('div[data-price]')
for product in products:
name = product.select_one('.name').text
price = product['data-price']
print(f"{name}: {price}원")
# n번째 요소 선택
first_row = soup.select_one('#data-table tr:nth-child(2)') # 첫 번째 데이터 행
print([td.text for td in first_row.find_all('td')])
# 복합 선택자
products_with_high_price = soup.select('.product[data-price="20000"]')
for product in products_with_high_price:
print(product.select_one('.name').text)
4. 실전 예제: 간단한 스크래퍼 만들기
이제 배운 내용을 종합하여 실전 스크래퍼를 만들어 보겠습니다.
4.1 뉴스 헤드라인 수집기
import requests
from bs4 import BeautifulSoup
import time
class NewsHeadlineScraper:
"""뉴스 헤드라인을 수집하는 스크래퍼"""
def __init__(self):
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
self.session = requests.Session()
self.session.headers.update(self.headers)
def fetch_page(self, url):
"""웹 페이지를 가져옵니다."""
try:
response = self.session.get(url, timeout=10)
response.raise_for_status()
return response.text
except requests.RequestException as e:
print(f"페이지 요청 실패: {e}")
return None
def parse_headlines(self, html, selector):
"""HTML에서 헤드라인을 추출합니다."""
soup = BeautifulSoup(html, 'lxml')
headlines = []
elements = soup.select(selector)
for element in elements:
title = element.get_text(strip=True)
link = element.get('href', '')
if title:
headlines.append({
'title': title,
'link': link
})
return headlines
def scrape(self, url, selector, delay=1):
"""주어진 URL에서 헤드라인을 스크래핑합니다."""
print(f"스크래핑 시작: {url}")
html = self.fetch_page(url)
if not html:
return []
headlines = self.parse_headlines(html, selector)
print(f"{len(headlines)}개의 헤드라인을 찾았습니다.")
time.sleep(delay) # 서버 부하 방지
return headlines
# 사용 예시 (실제 사용 시 해당 사이트의 이용약관을 확인하세요)
if __name__ == '__main__':
scraper = NewsHeadlineScraper()
# 예시 (실제 URL과 선택자는 대상 사이트에 맞게 수정)
headlines = scraper.scrape(
url='https://example.com/news',
selector='a.headline'
)
for idx, headline in enumerate(headlines, 1):
print(f"{idx}. {headline['title']}")
print(f" 링크: {headline['link']}")
4.2 테이블 데이터 추출기
import requests
from bs4 import BeautifulSoup
import csv
def extract_table_data(url, table_selector='table'):
"""웹 페이지의 테이블 데이터를 추출합니다."""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
table = soup.select_one(table_selector)
if not table:
print("테이블을 찾을 수 없습니다.")
return []
data = []
rows = table.find_all('tr')
for row in rows:
# 헤더 셀 또는 데이터 셀 추출
cells = row.find_all(['th', 'td'])
row_data = [cell.get_text(strip=True) for cell in cells]
if row_data:
data.append(row_data)
return data
def save_to_csv(data, filename):
"""데이터를 CSV 파일로 저장합니다."""
with open(filename, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerows(data)
print(f"데이터가 {filename}에 저장되었습니다.")
# 사용 예시
if __name__ == '__main__':
# 예시 URL (실제 사용 시 적절한 URL로 변경)
data = extract_table_data(
url='https://example.com/data-table',
table_selector='#main-table'
)
if data:
for row in data[:5]: # 처음 5행만 출력
print(row)
save_to_csv(data, 'extracted_data.csv')
4.3 이미지 다운로더
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
import os
import time
class ImageDownloader:
"""웹 페이지에서 이미지를 다운로드하는 클래스"""
def __init__(self, download_dir='images'):
self.download_dir = download_dir
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
# 다운로드 디렉토리 생성
if not os.path.exists(download_dir):
os.makedirs(download_dir)
def get_image_urls(self, page_url, img_selector='img'):
"""페이지에서 이미지 URL을 추출합니다."""
response = requests.get(page_url, headers=self.headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
images = soup.select(img_selector)
image_urls = []
for img in images:
src = img.get('src') or img.get('data-src')
if src:
# 상대 URL을 절대 URL로 변환
full_url = urljoin(page_url, src)
image_urls.append(full_url)
return image_urls
def download_image(self, url, filename=None):
"""이미지를 다운로드합니다."""
try:
response = requests.get(url, headers=self.headers, timeout=30)
response.raise_for_status()
# 파일명이 없으면 URL에서 추출
if not filename:
parsed = urlparse(url)
filename = os.path.basename(parsed.path) or 'image.jpg'
filepath = os.path.join(self.download_dir, filename)
with open(filepath, 'wb') as f:
f.write(response.content)
print(f"다운로드 완료: {filename}")
return filepath
except requests.RequestException as e:
print(f"다운로드 실패 ({url}): {e}")
return None
def download_all(self, page_url, img_selector='img', delay=1):
"""페이지의 모든 이미지를 다운로드합니다."""
image_urls = self.get_image_urls(page_url, img_selector)
print(f"{len(image_urls)}개의 이미지를 찾았습니다.")
downloaded = []
for idx, url in enumerate(image_urls, 1):
print(f"[{idx}/{len(image_urls)}] 다운로드 중...")
filepath = self.download_image(url)
if filepath:
downloaded.append(filepath)
time.sleep(delay) # 서버 부하 방지
return downloaded
# 사용 예시
if __name__ == '__main__':
downloader = ImageDownloader(download_dir='downloaded_images')
# 예시 URL (실제 사용 시 적절한 URL로 변경)
downloaded_files = downloader.download_all(
page_url='https://example.com/gallery',
img_selector='div.gallery img',
delay=2
)
print(f"\n총 {len(downloaded_files)}개의 이미지를 다운로드했습니다.")
5. 팁과 주의사항
5.1 효율적인 스크래핑을 위한 팁
- Session 사용: 같은 사이트에 여러 번 요청할 때는 requests.Session()을 사용하여 연결을 재사용합니다.
- 적절한 딜레이: 요청 사이에 time.sleep()을 사용하여 서버에 과부하를 주지 않습니다.
- 에러 처리: 네트워크 오류, 타임아웃 등에 대한 적절한 예외 처리를 추가합니다.
- 캐싱 활용: 같은 페이지를 반복해서 요청하지 않도록 결과를 캐싱합니다.
- 로깅: 스크래핑 과정을 로깅하여 문제 발생 시 디버깅에 활용합니다.
5.2 흔한 문제와 해결책
- 인코딩 문제: response.encoding을 명시적으로 설정하거나 chardet 라이브러리를 사용합니다.
- 403 Forbidden: User-Agent 헤더를 설정하거나 다른 헤더를 추가합니다.
- 동적 콘텐츠: JavaScript로 로드되는 콘텐츠는 requests로 가져올 수 없습니다. 다음 편에서 Selenium을 사용한 해결 방법을 배웁니다.
- IP 차단: 요청 빈도를 줄이거나 프록시를 사용합니다.
마무리
이번 편에서는 Python 웹 스크래핑의 기초를 배웠습니다. requests 라이브러리로 웹 페이지를 가져오고, BeautifulSoup으로 HTML을 파싱하여 원하는 데이터를 추출하는 방법을 익혔습니다.
다음 4편에서는 더 복잡한 웹 스크래핑 시나리오를 다룹니다. JavaScript로 동적 생성되는 콘텐츠를 처리하기 위한 Selenium, 로그인이 필요한 사이트 스크래핑, 그리고 수집한 데이터를 저장하는 다양한 방법을 배울 예정입니다.
시리즈 안내: Python 자동화 마스터 시리즈는 계속됩니다. 다음 편에서 더 심화된 웹 스크래핑 기술을 배워보세요!