파이썬 자동화 #3 웹 스크래핑 ①: httpx와 BeautifulSoup으로 정적 페이지 수집
매일 브라우저로 들어가 확인하는 페이지가 하나쯤 있을 것입니다. 상품 가격, 공지사항, 재고 상태처럼 내용은 매번 다르지만 확인하는 동작은 똑같습니다. 페이지를 열고, 같은 위치를 보고, 어제와 달라졌는지 비교합니다. 이렇게 동작이 정해져 있는 반복은 코드가 대신할 수 있습니다. 이번 글에서는 httpx로 HTML을 받아오고 BeautifulSoup으로 원하는 부분만 골라내는 정적 페이지 스크래핑을 다루겠습니다.
- #1 스크립트 첫걸음
- #2 엑셀 자동화
- #3 웹 스크래핑 ①: 정적 페이지 수집 ← 이번 글
- #4 웹 스크래핑 ②: 동적 페이지
- #5 메일과 알림
- #6 스케줄링
- #7 CLI 도구로 포장
HTML이 곧 데이터입니다 #
브라우저가 보여주는 화면 뒤에는 항상 HTML이 있습니다. 가격표도, 공지 목록도, 재고 표시도 결국은 태그와 텍스트입니다. 스크래핑은 이 HTML에서 원하는 태그를 찾아 텍스트를 꺼내는 작업입니다.
그래서 첫 단계는 코드가 아니라 관찰입니다. 수집하고 싶은 페이지를 열고, 원하는 값 위에서 우클릭한 뒤 “검사"를 누르면 개발자 도구의 Elements 탭이 해당 태그를 보여줍니다. 여기서 확인할 것은 두 가지입니다.
- 원하는 값이 어떤 태그 안에 있는지 (
<p>,<span>,<a>등) - 그 태그를 다른 태그와 구분해 주는 표식이 무엇인지 (class, id, 부모 구조)
이번 글의 실습 대상은 books.toscrape.com입니다. 스크래핑 연습용으로 공개된 가상 서점 사이트라서 마음 놓고 연습할 수 있습니다. 책 한 권이 article.product_pod 태그 하나이고, 그 안에 제목과 가격이 들어 있는 구조입니다.
준비: 패키지 두 개 #
프로젝트에 httpx와 beautifulsoup4를 추가합니다.
uv add httpx beautifulsoup4httpx는 HTTP 요청을 보내는 라이브러리입니다. 오래 쓰여 온 requests와 API가 거의 같으면서 기본 타임아웃이 내장돼 있고 HTTP/2와 비동기까지 지원하므로, 새로 시작한다면 httpx를 권합니다. beautifulsoup4는 받아온 HTML을 파싱해서 태그 단위로 탐색하게 해 주는 라이브러리입니다.
httpx로 페이지 요청하기 #
import httpx
headers = {
"User-Agent": "Mozilla/5.0 (compatible; book-scraper/1.0)"
}
resp = httpx.get(
"https://books.toscrape.com/",
headers=headers,
timeout=10.0,
)
resp.raise_for_status()
print(resp.status_code) # 200
print(resp.text[:300]) # HTML 앞부분세 가지를 짚겠습니다.
- User-Agent: 요청을 보낼 때 “누가 요청했는지"를 알리는 헤더입니다. 기본값을 그대로 두면
python-httpx/0.x로 나가는데, 이를 차단하는 사이트가 있습니다. 자신을 식별할 수 있는 문자열을 넣어 두는 편이 좋습니다. - timeout: 응답이 없을 때 무한정 기다리지 않도록 제한 시간을 둡니다.
httpx는 기본 5초가 설정돼 있지만, 명시해 두면 의도가 분명해집니다. - raise_for_status: 404나 500처럼 실패한 응답이면 예외를 던집니다. 실패한 HTML을 모르고 파싱하는 사고를 막아 줍니다.
BeautifulSoup으로 골라내기 #
받아온 HTML 문자열을 BeautifulSoup에 넘기면 태그 단위로 탐색할 수 있는 객체가 됩니다. 탐색 방법은 여러 가지가 있지만 CSS 선택자를 쓰는 select 하나로 거의 다 해결됩니다.
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
books = soup.select("article.product_pod") # 책 한 권 = article 하나
print(len(books)) # 20
first = books[0]
title = first.select_one("h3 a")["title"] # 속성에서 꺼내기
price = first.select_one("p.price_color").text # 텍스트로 꺼내기
print(title, price)select는 조건에 맞는 태그를 전부 리스트로, select_one은 첫 번째 하나만 돌려줍니다. 자주 쓰는 CSS 선택자는 이 정도면 충분합니다.
article.product_pod: class가product_pod인article태그#content: id가content인 태그h3 a:h3안쪽 어딘가의a태그ul > li:ul의 바로 아래 자식lia[href]:href속성을 가진a태그
개발자 도구에서 본 구조를 선택자로 옮기고, select 결과 개수가 화면에서 센 개수와 맞는지 확인합니다. 이 과정이 스크래핑의 본체입니다.
완성 예제: 제목과 가격을 CSV로 #
요청, 파싱, 저장을 함수로 나눠 한 파일로 정리하겠습니다.
import csv
import httpx
from bs4 import BeautifulSoup
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; book-scraper/1.0)"}
def fetch(url: str) -> str:
resp = httpx.get(url, headers=HEADERS, timeout=10.0)
resp.raise_for_status()
return resp.text
def parse(html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
rows = []
for book in soup.select("article.product_pod"):
rows.append({
"title": book.select_one("h3 a")["title"],
"price": book.select_one("p.price_color").text.lstrip("£"),
})
return rows
def save(rows: list[dict], path: str) -> None:
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["title", "price"])
writer.writeheader()
writer.writerows(rows)
if __name__ == "__main__":
rows = parse(fetch("https://books.toscrape.com/"))
save(rows, "books.csv")
print(f"{len(rows)}권 저장 완료")uv run scrape.py
# 20권 저장 완료books.csv를 열면 제목과 가격이 표로 정리돼 있습니다. 2편에서 다룬 엑셀 자동화와 이어 붙이면, 수집부터 보고서 정리까지 한 번에 흐르는 파이프라인이 됩니다.
페이지네이션 순회 #
목록이 여러 페이지에 걸쳐 있으면 URL 패턴을 찾아 순회합니다. books.toscrape.com은 catalogue/page-2.html 형식으로 페이지가 이어집니다.
import time
BASE = "https://books.toscrape.com/catalogue/page-{}.html"
all_rows = []
for page in range(1, 6): # 우선 1〜5페이지만
html = fetch(BASE.format(page))
all_rows.extend(parse(html))
print(f"{page}페이지 수집, 누적 {len(all_rows)}권")
time.sleep(1) # 요청 사이 1초 대기
save(all_rows, "books_all.csv")핵심은 time.sleep(1)입니다. 사람은 페이지를 1초에 한 장도 못 넘기지만, 코드는 멈추지 않으면 초당 수십 건을 요청합니다. 상대 서버 입장에서는 공격과 구분되지 않습니다. 요청 사이에 간격을 두는 것은 선택이 아니라 기본기입니다.
지켜야 할 선 #
기술적으로 가능한 것과 해도 되는 것은 다릅니다. 스크래핑 코드를 돌리기 전에 다음을 확인하겠습니다.
- robots.txt: 사이트 주소 뒤에
/robots.txt를 붙이면 사이트가 자동 수집에 대해 밝힌 방침이 나옵니다.Disallow로 지정된 경로는 수집하지 않고,Crawl-delay가 있으면 그 간격을 지킵니다. - 이용약관: 자동 수집을 명시적으로 금지하는 서비스가 있습니다. 로그인이 필요한 페이지라면 특히 약관을 먼저 확인해야 합니다.
- 요청 빈도: 간격 없이 대량 요청을 보내면 상대 서버에 실제 부담을 줍니다.
sleep을 넣고, 필요한 페이지만 받습니다. - 개인 자동화와 서비스화의 차이: 내 확인 작업을 하루 한 번 대신하는 스크립트와, 수집한 데이터를 재배포하거나 서비스로 제공하는 일은 전혀 다른 문제입니다. 후자는 저작권과 법적 검토가 필요한 영역이므로 이 시리즈의 범위를 넘습니다.
받아온 HTML이 비어 있다면 #
이 방식이 통하지 않는 페이지도 있습니다. 응답은 200인데 정작 원하는 데이터가 HTML에 없는 경우입니다.
resp = httpx.get("https://example-spa.com/products")
print(resp.text)
# <div id="root"></div> 만 있고 상품 데이터는 없음이런 페이지는 서버가 빈 껍데기 HTML만 주고, 브라우저가 자바스크립트를 실행한 뒤에야 데이터를 채워 넣는 구조입니다. httpx는 자바스크립트를 실행하지 않으므로 빈 껍데기만 받게 됩니다. 개발자 도구에는 보이는데 resp.text에는 없다면 이 경우입니다. 이때는 브라우저 자체를 코드로 조종해야 하고, 그 방법은 다음 편에서 다루겠습니다.
정리 #
이번 글에서 만든 흐름입니다.
- 개발자 도구로 원하는 값의 태그와 class를 관찰
httpx.get에 User-Agent와 timeout을 명시하고raise_for_status로 실패 차단BeautifulSoup의select와 CSS 선택자로 원하는 태그만 추출- 제목과 가격을 뽑아 CSV로 저장하는 완성 스크립트
- URL 패턴 순회 +
time.sleep으로 여러 페이지 수집 - robots.txt, 이용약관, 요청 빈도라는 지켜야 할 선
다음 글(#4 웹 스크래핑 ②: 동적 페이지)에서는 자바스크립트가 데이터를 채우는 페이지를 다루겠습니다. Playwright로 실제 브라우저를 띄워 렌더링이 끝난 화면에서 데이터를 꺼내는 방법을 정리합니다.