강의

멘토링

커뮤니티

인프런 커뮤니티 질문&답변

rlwjd31님의 프로필 이미지
rlwjd31

작성한 질문수

[신규 개정판] 이것이 진짜 크롤링이다 - 실전편 (인공지능 수익화)

3단계 - 마지막 페이지 체크하는 방법

원하는 값이 없을 때

작성

·

40

·

수정됨

0

안녕하세요 강의 잘 수강하고 있습니다.

현재 subject, link, content, press, date를 추출하는데 만약 해당 값들 중 하나가 없다면 검증은 어떤 식으로 하시나요??

제가 생각한 방법은 pydantic으로 검증하여 해결할 수 있지 않을까 생각하는데 너무 오버엔지니어링인지 강사님은 어떻게 유연하게 처리하는지 궁금합니다.

 

예시를 들기 위해 pydantic으로 한 코드 첨부합니다.

 


from typing import Annotated
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlencode
from datetime import datetime
from pydantic import AfterValidator, BaseModel, HttpUrl, field_validator


def non_empty_str(v: str) -> str:
  s = (v or "").strip()
  if not s:
    raise ValueError("빈 문자열입니다.")
  
  return s

def valid_url(url: str) -> str:
  try:
    response = requests.get(url)
    # throw error if status 4xx, 5xx
    response.raise_for_status()

    return url
  except Exception as _:
    raise ValueError("유효하지 않은 URL입니다.")

def valid_date(date: str) -> str:
  v = (date or "").strip()

  if not v:
    raise ValueError("빈 문자열입니다.")

  try:
    datetime.strptime(v, "%Y-%m-%d %H:%M:%S")

    return date
  except ValueError:
    raise ValueError("유효하지 않은 날짜 형식입니다.")

class NewsItem(BaseModel):
  subject: Annotated[str, AfterValidator(non_empty_str)]
  detail_article_url: Annotated[str, AfterValidator(valid_url)]
  content: Annotated[str, AfterValidator(non_empty_str)]
  press: Annotated[str, AfterValidator(non_empty_str)]
  article_date: Annotated[str, AfterValidator(valid_date)]


ROOT = "https://finance.naver.com/"
PATH = "news/mainnews.naver"


def get_news_page_url(page: int, date: str):
  base_url = urljoin(ROOT, PATH)
  query_string = urlencode({"date": date, "page": page})

  return f"{base_url}?{query_string}"

def get_detail_news_url(path):
  return urljoin(ROOT, path)

def get_current_date(format: str ="%Y-%m-%d") -> str:
  now = datetime.now()

  return now.strftime(format)  


def crawl_news_per_page(soup: BeautifulSoup):
  result = []
  news_per_page = soup.select(".block1")
  
  for article_card in news_per_page:
    # subject
    subject = article_card.select_one(".articleSubject > a").text
    
    # detail article link
    detail_article_url = get_detail_news_url(article_card.select_one(".articleSubject > a").get("href"))

    # content
    content_tag = article_card.select_one(".articleSummary")
    content = content_tag.contents[0].strip()
    press = content_tag.select_one(".press").text.strip()
    article_date = content_tag.select_one(".wdate").text.strip()

    new_item = NewsItem(subject=subject, detail_article_url=detail_article_url, content=content, press=press, article_date=article_date)

    result.append(
      new_item.model_dump()
    )
  
  return result



def crawl_all_news(page_total_count: int, date: str):
  result = []

  for page in range(1, page_total_count + 1):
    request_url = get_news_page_url(page, date)
    response = requests.get(request_url)
    soup = BeautifulSoup(response.text, "html.parser")
    result.extend(crawl_news_per_page(soup))

    # 마지막 페이지 검증
    if (not soup.select_one(".pgRR")):
      break

  return result



all_news = crawl_all_news(20, get_current_date())
print(all_news)

답변 2

0

스타트코딩님의 프로필 이미지
스타트코딩
지식공유자

저는 보통 try except 나 if 를 이용해서 간단하게 처리해서 pydantic 한 처리방법에 대해서는 잘모르겠네요 🤔

 

대신 제미나이 답변을 첨부해 드립니다!

안녕하세요, 수강생님. 질문 감사합니다. 코드까지 깔끔하게 첨부해주셔서 질문의 의도를 정확히 파악하는 데 큰 도움이 되었습니다. Pydantic을 활용한 접근 방식, 아주 훌륭합니다.

결론부터 말씀드리면, 수강생님이 작성하신 코드는 '오버 엔지니어링'이 전혀 아닙니다. 오히려 실무에서 적극 권장되는 '견고한(robust) 엔지니어링'에 가깝습니다. 제가 강의에서 (아마도) 간단한 딕셔너리(dict)로 처리한 것은 강의의 핵심 주제(크롤링)에 집중하기 위함이었을 겁니다.

수강생님의 접근 방식(Pydantic)이 왜 좋은지, 그리고 '유연한 처리'란 무엇인지 설명해 드릴게요.

 

🧐 Pydantic을 쓰는 것이 왜 좋은가?

 

  1. 명확한 데이터 구조 (Schema):NewsItem 모델을 보는 것만으로도 "아, 이 크롤러는 5개의 필드를 수집하고, 각 필드는 이런 타입(str, HttpUrl)이구나"라고 즉시 알 수 있습니다.

  2. 관심사의 분리 (SoC):

    • crawl_... 함수는 HTML에서 데이터를 '추출(Extract)'하는 책임만 집니다.

    • NewsItem 모델은 추출된 데이터가 '올바른지 검증(Validate)'하는 책임을 집니다.

    • 코드가 훨씬 깨끗해지고 유지보수가 쉬워집니다.

  3. 조기 발견 (Fail-fast): 크롤링 도중 subject가 비어있거나 detail_article_url이 잘못된 형식이라면, Pydantic이 즉시 ValidationError를 발생시킵니다. 데이터가 한참 뒤에 DB에 저장되거나 다른 API로 전송될 때 에러가 나는 것보다 훨씬 좋습니다.


 

🎛 "유연한 처리" vs "견고한 처리"

 

'유연하게 처리한다'는 것이 에러를 무시하고 대충 넘어가는 것을 의미하지는 않습니다.

1. "간단하지만 지저분해지기 쉬운" 유연한 처리 (강의에서 쓸 법한 방식)

Pydantic 없이 crawl_news_per_page 함수 내에서 모든 것을 처리하는 방식입니다.

Python

def crawl_news_per_page(soup: BeautifulSoup):
    result = []
    news_per_page = soup.select(".block1")
    
    for article_card in news_per_page:
        try:
            subject = article_card.select_one(".articleSubject > a").text
            detail_article_url = get_detail_news_url(article_card.select_one(".articleSubject > a").get("href"))
            
            content_tag = article_card.select_one(".articleSummary")
            content = content_tag.contents[0].strip()
            press = content_tag.select_one(".press").text.strip()
            article_date = content_tag.select_one(".wdate").text.strip()
            
            # 여기서 직접 유효성 검사 (점점 지저분해짐)
            if not subject or not content:
                print("경고: 빈 제목이나 내용이 있습니다. 이 항목은 건너뜁니다.")
                continue # 이 항목을 스킵
                
            result.append({
              "subject": subject,
              "detail_article_url": detail_article_url,
              "content": content,
              "press": press,
              "article_date": article_date
            })

        except AttributeError as e:
            # .text나 .get('href')를 시도할 때 태그가 None이면 발생하는 에러
            print(f"파싱 에러 발생: {e}. 이 항목은 건너뜁니다.")
            continue # 이 항목을 스킵
        except Exception as e:
            # 기타 예외
            print(f"알 수 없는 에러: {e}")
            
    return result
  • 문제점:try...except 블록이 거대해지고, 파싱 로직과 검증 로직, 에러 처리 로직이 뒤섞여 '스파게티 코드'가 되기 쉽습니다.

2. 수강생님이 선택한 "견고한" 처리 (Pydantic)

이 방식이 훨씬 낫습니다. 다만, 수강생님의 코드에서 두 가지 치명적인 문제를 수정해야 합니다.


 

💡 Pydantic 접근법 실전 꿀팁 (코드 개선)

 

수강생님의 코드는 90% 훌륭합니다. 하지만 valid_url 검증자와 파싱 부분에 실무에서 문제가 될 수 있는 부분이 있습니다.

1. Validator는 I/O 작업을 해선 안 됩니다.

  • 문제:valid_url 함수 내부에서 requests.get(url)를 호출하고 계십니다.

  • 이유: Pydantic 모델을 생성(초기화)할 때마다 매번 HTTP 요청이 발생합니다. 뉴스 100개를 크롤링하면, 모델을 만들 때 100번, 나중에 실제 데이터를 쓸 때 100번, 총 200번의 요청이 나갈 수 있습니다. Validator는 데이터의 형식(format)이 유효한지만 빠르고 가볍게 검사해야 합니다. URL이 실제로 연결 가능한지(reachability)는 나중에 그 URL을 사용할 때 검사하는 것이 맞습니다.

  • 해결: Pydantic이 제공하는 HttpUrl 타입을 사용하면 requests 호출 없이도 URL 형식을 검증해 줍니다.

2. 파싱(Parsing) 단계는 AttributeError에 방어적이어야 합니다.

  • 문제:subject = article_card.select_one(".articleSubject > a").text

  • 이유: 만약 .articleSubject > a 셀렉터에 해당하는 태그가 없다면select_oneNone을 반환합니다. None.text를 호출하는 순간 AttributeError가 발생하고, Pydantic 모델은 구경도 못하고 프로그램이 죽습니다.

  • 해결: 파싱 단계에서는 None을 안전하게 처리하고, None 또는 빈 문자열을 Pydantic 모델에 전달해야 합니다. 그 뒤는 Pydantic의 AfterValidator(non_empty_str)가 알아서 처리해 줄 것입니다.

 

🔧 개선된 코드 예시

 

제가 수강생님의 코드를 개선해 보겠습니다.

Python

# ... (requests, BeautifulSoup, datetime 등 import는 동일)
from typing import Annotated, Optional
from pydantic import BaseModel, HttpUrl, field_validator, ValidationInfo

# --- Pydantic 모델 정의 ---

def non_empty_str(v: str) -> str:
    """공백 제거 후 빈 문자열인지 검사하는 Validator"""
    if not v or not v.strip():
        raise ValueError("필수 필드가 비어있습니다.")
    return v.strip()

def valid_date_format(v: str) -> str:
    """날짜 형식이 'YYYY-MM-DD HH:MM:SS'인지 검사하는 Validator"""
    v = v.strip()
    try:
        datetime.strptime(v, "%Y-%m-%d %H:%M:%S")
        return v
    except ValueError:
        raise ValueError("날짜 형식이 'YYYY-MM-DD HH:MM:SS'와 다릅니다.")

class NewsItem(BaseModel):
    # str 타입에 non_empty_str 검증을 적용합니다.
    subject: Annotated[str, AfterValidator(non_empty_str)]
    
    # HttpUrl 타입을 사용해 URL 형식을 검증합니다. (I/O 없음!)
    detail_article_url: HttpUrl 
    
    content: Annotated[str, AfterValidator(non_empty_str)]
    press: Annotated[str, AfterValidator(non_empty_str)]
    article_date: Annotated[str, AfterValidator(valid_date_format)]

# --- 크롤링 함수 정의 ---

ROOT = "https://finance.naver.com/"
PATH = "news/mainnews.naver"

def get_detail_news_url(path):
    return urljoin(ROOT, path)

def safe_get_text(tag, selector):
    """안전하게 .text를 추출하는 헬퍼 함수"""
    selected = tag.select_one(selector)
    return selected.text.strip() if selected else "" # None 대신 빈 문자열 반환

def safe_get_content(tag, selector):
    """본문 내용(press, wdate 제외)을 안전하게 추출하는 헬퍼 함수"""
    selected = tag.select_one(selector)
    if not selected:
        return ""
    
    # .press, .wdate 태그를 제외한 순수 텍스트 노드를 찾습니다.
    content_text = ""
    for child in selected.contents:
        if child.name not in ['span', 'a']: # press/wdate가 span/a 태그일 경우
            content_text += str(child).strip()
            
    return content_text.strip() if content_text else ""


def crawl_news_per_page(soup: BeautifulSoup):
    result = []
    news_per_page = soup.select(".block1")
  
    for article_card in news_per_page:
        # 1. 안전하게 데이터 추출 (AttributeError 방지)
        #    태그가 없으면 None이나 ""이 subject_raw 등에 할당됩니다.
        subject_raw = safe_get_text(article_card, ".articleSubject > a")
        url_raw = article_card.select_one(".articleSubject > a")
        detail_article_url_raw = get_detail_news_url(url_raw.get("href")) if url_raw else ""
        
        content_raw = safe_get_content(article_card, ".articleSummary")
        press_raw = safe_get_text(article_card, ".articleSummary .press")
        article_date_raw = safe_get_text(article_card, ".articleSummary .wdate")

        try:
            # 2. Pydantic 모델로 검증
            #    여기서 non_empty_str, HttpUrl, valid_date_format 등이 작동합니다.
            news_item = NewsItem(
                subject=subject_raw,
                detail_article_url=detail_article_url_raw,
                content=content_raw,
                press=press_raw,
                article_date=article_date_raw
            )
            
            # 검증 성공 시 결과에 추가
            result.append(news_item.model_dump())

        except Exception as e:
            # 3. Pydantic 검증 실패 시 에러 처리
            #    (예: subject가 비어있거나, URL 형식이 아닐 때)
            print(f"--- 데이터 검증 실패 (항목 스킵) ---")
            print(f"에러: {e}")
            print(f"원본 데이터: [제목: {subject_raw[:20]}...], [URL: {detail_article_url_raw}]")
            print("---------------------------------")
  
    return result

# ... (crawl_all_news, get_news_page_url 등 나머지 코드는 동일)

 

요약

 

  1. 수강생님의 Pydantic 접근은 '오버 엔지니어링'이 아니라 '베스트 프랙티스'입니다.

  2. '유연한 처리'란 try...except로 대충 넘기는 것이 아니라, 파싱(추출)과 검증(Validation)의 책임을 분리하고, Pydantic 같은 도구를 사용해 데이터 무결성을 '견고하게' 보장하는 것입니다.

  3. Validator 내부에서 requests.get 같은 I/O 작업은 절대 금물입니다. HttpUrl 타입을 활용하세요.

  4. 파싱 로직은 AttributeError가 나지 않도록 방어적으로 작성하고 (예: safe_get_text), 검증은 Pydantic에 맡기세요.

 

0

안녕하세요, 인프런 AI 인턴입니다.

rlwjd31님께서 제공해주신 pydantic을 활용한 검증 방법은 충분히 효율적이고 안전한 처리 방안입니다. Pydantic은 데이터 유효성 검사를 간편하게 구현할 수 있는 강력한 도구로, 유효한 URL이나 날짜 형식을 검증할 때 매우 유용합니다.

적용하신 방법 외에 유연성을 높이기 위한 몇 가지 팁을 추가로 응용할 수 있습니다:

  1. 클래스 레벨의 매개변수 기본값 설정: Pydantic의 BaseModel에서 필수 필드로 빈 문자열을 할당하거나 필드에 기본값을 설정하면, 특정 필드가 없을 때 유연하게 대처할 수 있습니다.

  2. Optional 타입 활용: 필드가 없는 경우를 대비하여 Optional 타입을 사용하면 데이터를 검증할 때 더 유연하게 처리할 수 있습니다. 필드가 없을 때 기본값으로 None을 할당하면 됩니다.

  3. 로깅 및 예외 처리 추가: 데이터 수집 및 검증 과정에서 발생할 수 있는 예외를 로깅하고, 구조화된 예외 처리 코드를 작성하면 디버깅과 유지보수가 편리해집니다.

  4. 동적 필드 생성: 데이터를 크롤링할 때 상황에 따라 동적으로 필드를 생성하는 방법을 고려해볼 수 있습니다. 예를 들어, 모든 필드가 필수적이지 않은 경우 try-except 블록을 사용하여 발생하는 예외를 처리하실 수 있습니다.

위의 방법들을 통해 좀 더 유연하고 범용적인 코드 구조를 갖출 수 있을 것입니다. 강사님이 제공하신 원래 코드가 어떻게 구현되어 있는지는 모르지만, 위의 방법들을 활용하면 자신의 코드에 맞게 수정하고 개선할 수 있을 것입니다.

추가로, Spring Boot에서 유효성 검증을 알아보시려면 Spring Boot Validation 문서를 참조해주세요.

코드 공부에 도움이 되었으면 좋겠습니다! 좋은 하루 보내세요.

rlwjd31님의 프로필 이미지
rlwjd31

작성한 질문수

질문하기