실습 코드를 쭉 짜다 보면 매번 쓰는 패턴이 있다. requests로 요청 보내고, BeautifulSoup으로 파싱하고, 원하는 데이터만 뽑아서 csv로 저장하는 흐름. 정적인 페이지에서 막히면 Selenium으로 넘어가는 흐름도 마찬가지다. 실습에서 실제로 썼던 문법들을 순서대로 정리해본다.
import requests
url = "https://jsonplaceholder.typicode.com/users"
response = requests.get(url)
response.text는 문자열이다. 근데 API가 내려주는 데이터는 사실상 JSON 형태라서, 이걸 파이썬이 다룰 수 있는 딕셔너리나 리스트로 바꿔줘야 한다. 이때 쓰는 게 json 모듈이다.
import json
datas = json.loads(response.text) # JSON 문자열 -> 파이썬 객체 (역직렬화)
json_datas = json.dumps(datas) # 파이썬 객체 -> JSON 문자열 (직렬화)
loads는 "load string"이라고 외우면 편하다. JSON 문자열을 읽어서(load) 파이썬 객체로 만드는 거니까. 반대로dumps는 파이썬 객체를 문자열로 덤프(dump)하는 거다.
받아온 데이터가 리스트 안에 딕셔너리가 여러 개 들어있는 구조라면, map과 lambda로 원하는 필드만 뽑아내는 패턴을 자주 쓰게 된다.
list(map(lambda data: data["email"].split("@")[0], datas))
HTML 문자열을 파싱할 때는 이렇게 시작한다.
from bs4 import BeautifulSoup as bs
soup = bs(tags, "html.parser")
원하는 태그를 찾는 방법은 크게 두 갈래다. 하나는 find / find_all 계열.
soup.find("li") # 조건에 맞는 첫 번째 요소
soup.find("li", class_="even") # 클래스까지 지정
soup.find_all("li", class_="even") # 조건에 맞는 걸 전부 리스트로
class는 파이썬 예약어라서class_처럼 언더바를 하나 붙여야 한다는 게 처음 쓸 때 헷갈리는 부분이다.
다른 하나는 CSS 선택자를 그대로 문자열로 넘기는 select / select_one이다. 실무 크롤링 코드에서는 오히려 이쪽을 더 많이 쓰게 된다.
soup.select(".wrap_song_info > .rank01 a") # CSS 선택자와 동일한 문법
soup.select_one(".result_details") # 조건에 맞는 첫 번째 요소만
>는 바로 아래 자식만 선택한다는 뜻이고, 뒤에 공백이 오면 자손 전체 중에서 찾는다는 뜻이다. 브라우저 개발자도구에서 요소를 우클릭해 "Copy selector" 하면 이 문법 그대로 나오는 경우가 많아서, CSS 선택자에 익숙해지면 크롤링 속도가 확 빨라진다.
태그를 찾은 다음에는 .text로 내용만 뽑아낸다.
li1 = soup.find("li")
li1.text
속성값이 필요하면 딕셔너리처럼 대괄호로 접근한다.
img_tag["src"]
크롤링하다 보면 광고나 배너 같은 게 같이 딸려와서, 원하는 리스트 뒤쪽에 필요 없는 데이터가 붙는 경우가 있다. 이럴 때 뒤에서부터 잘라내는 패턴을 쓴다.
review_content = chrome.find_elements(By.CLASS_NAME, "desc")
moving_review_content = list(map(lambda tag: tag.text, review_content))[:len(review_content) - 16]
리스트 길이에서 16을 빼는 식으로 뒤쪽 16개를 잘라내는 방식이다. 정확한 개수는 실제로 찍어보면서 맞춰야 하는 게 좀 번거롭긴 하다.
여러 리스트를 하나로 묶을 때는 zip을 쓴다.
moving_review_list = list(zip(recommen, moving_review_score, moving_review_content, moving_review_writer))
zip은 길이가 가장 짧은 리스트를 기준으로 묶인다. 리스트별로 길이가 다르면 일부 데이터가 통째로 누락될 수 있어서, 묶기 전에len()으로 각 리스트 길이부터 확인하는 습관이 필요하다.
import csv
import os
base_path = r"개인 파일주소"
file_name = r"melon_rank.csv"
file_path = os.path.join(base_path, file_name)
with open(file_path, 'w', newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["순위", "곡제목", "가수", "앨범표지", "좋아요수"])
writer.writerows(melon_rank)
몇 가지 포인트가 있다. newline=""을 안 넣으면 윈도우에서 줄마다 빈 줄이 하나씩 더 생긴다. 인코딩을 utf-8-sig로 하는 이유는 일반 utf-8로 저장하면 엑셀에서 한글이 깨져 보이기 때문이다. writerow는 한 줄, writerows는 여러 줄(리스트의 리스트)을 한 번에 쓴다는 차이도 있다.
폴더가 아직 없을 수도 있으니 저장 전에 만들어주는 것도 잊지 말아야 한다.
os.makedirs(musinsa_path, exist_ok=True)
exist_ok=True를 붙이면 폴더가 이미 있어도 에러 없이 넘어간다.
이미지 URL 리스트만 있으면, requests로 스트리밍하듯 받아서 파일로 저장할 수 있다.
for image_url in snap_images:
file_name = image_url.split("/")[-1].split("?")[0]
file_path = os.path.join(musinsa_path, file_name)
response = requests.get(image_url, stream=True)
with open(file_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
stream=True를 주면 이미지를 한 번에 메모리로 다 올리지 않고 조각(chunk) 단위로 받아온다. 큰 파일을 다룰 때는 이 방식이 안전하다. 저장 모드가 "wb"인 것도 눈여겨볼 부분인데, 이미지는 텍스트가 아니라 바이너리 데이터라서 w가 아니라 wb로 열어야 한다.
requests는 서버가 처음부터 완성된 HTML을 내려주는 정적인 페이지에서만 잘 먹힌다. 버튼을 눌러야 데이터가 뜨거나 스크롤해야 콘텐츠가 추가로 로드되는 사이트(CSR 방식)에서는 이 방식으로 못 긁어온다. 그럴 때 브라우저를 진짜로 띄워서 사람처럼 클릭하고 스크롤하게 만드는 게 Selenium이다.
from selenium.webdriver import Chrome
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
import time
options = Options()
user_agent = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"}
options.add_argument(f"user-agent={user_agent}")
chrome = Chrome(options=options)
chrome.get("https://example.com")
요소를 찾는 방식은 BeautifulSoup의 select와 비슷한데, By라는 걸 앞에 붙여서 어떤 기준으로 찾을지 지정해줘야 한다.
chrome.find_element(By.CLASS_NAME, "gLFyf") # 조건에 맞는 첫 번째 요소
chrome.find_elements(By.CLASS_NAME, "set_sido_cd_btn") # 조건에 맞는 전체를 리스트로
chrome.find_elements(By.CSS_SELECTOR, ".tab_list li") # CSS 선택자 그대로도 가능
단수형(
find_element)과 복수형(find_elements)을 구분해야 한다는 것도 실수하기 쉬운 지점이다.
찾은 요소는 클릭하거나 텍스트를 입력할 수 있다.
search_bar = chrome.find_element(By.CLASS_NAME, "gLFyf")
search_bar.send_keys("고양이")
search_bar.send_keys(Keys.RETURN) # 엔터키 입력
버튼을 누르고 나면 페이지가 로딩되는 시간이 필요하다. 그 사이에 데이터를 읽으려고 하면 아직 안 뜬 상태라 에러가 나기 때문에, time.sleep()으로 잠깐 기다려주는 게 거의 필수다.
seoul_button.click()
time.sleep(5)
스크롤을 끝까지 내려야 콘텐츠가 더 로드되는 사이트라면 자바스크립트를 직접 실행시킨다.
chrome.execute_script("window.scrollTo(0, document.body.scrollHeight)") # 맨 아래로
chrome.execute_script("window.scrollTo(0, 0)") # 맨 위로
이렇게 브라우저를 조작해서 페이지를 충분히 로드시킨 다음, 최종 HTML을 BeautifulSoup에 넘겨서 파싱하는 게 Selenium과 BeautifulSoup을 같이 쓰는 기본 패턴이다.
soup = bs(chrome.page_source, "html.parser")
chrome.page_source가 지금 브라우저 화면에 실제로 렌더링된 HTML 전체다.requests로 받은 정적인 HTML과 달리, 버튼 클릭이나 스크롤로 추가된 내용까지 다 포함되어 있다는 점이 핵심이다.
정리하고 보니 실습 자체는 스타벅스 매장 검색, 무신사 스냅 이미지, 네이버 영화 관람평, 멜론 차트, 알라딘 베스트셀러, 벨로그 최신글까지 꽤 다양한 사이트를 다뤘다. 사이트마다 구조가 다 다르니 셀렉터는 매번 새로 찾아야 하지만, 결국 뼈대는 같다. 요청 보내고, 파싱하고, 원하는 값만 뽑아서 zip으로 묶고, csv로 저장하는 흐름. 이 패턴 한 번 손에 익으면 사이트만 바뀌어도 응용이 꽤 빠르게 된다.