9/13 데이터 수집

똥강아지·6일 전
post-thumbnail

크롤링 실습하면서 계속 나온 문법들 정리

실습 코드를 쭉 짜다 보면 매번 쓰는 패턴이 있다. requests로 요청 보내고, BeautifulSoup으로 파싱하고, 원하는 데이터만 뽑아서 csv로 저장하는 흐름. 정적인 페이지에서 막히면 Selenium으로 넘어가는 흐름도 마찬가지다. 실습에서 실제로 썼던 문법들을 순서대로 정리해본다.


requests로 요청 보내고 JSON 다루기

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)하는 거다.

받아온 데이터가 리스트 안에 딕셔너리가 여러 개 들어있는 구조라면, maplambda로 원하는 필드만 뽑아내는 패턴을 자주 쓰게 된다.

list(map(lambda data: data["email"].split("@")[0], datas))

BeautifulSoup, find와 select 두 가지 방식

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()으로 각 리스트 길이부터 확인하는 습관이 필요하다.


csv로 저장하기

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로 열어야 한다.


Selenium, 페이지가 움직여야 할 때

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로 저장하는 흐름. 이 패턴 한 번 손에 익으면 사이트만 바뀌어도 응용이 꽤 빠르게 된다.

profile
짜아자안 에비츄 등장

0개의 댓글