Skip to content

crawling

siggu edited this page Nov 27, 2025 · 2 revisions

🕷️ 데이터 크롤링

→ 링크 수집 → HTML 파싱 → LLM 구조화 → 품질 평가


🔧 크롤링 방식

기술 스택 선택

방식 적용 대상 선택 이유
requests + BeautifulSoup 정적 페이지 ✅ 모든 보건소 사이트 해당
Selenium 동적 페이지 ❌ 해당 없음

수집하려는 모든 보건소 웹사이트는 정적 페이지로 구성되어 있어 requestsBeautifulSoup 사용


🔗 링크 수집

🚧 문제점

  • 각 보건소마다 웹사이트 구조가 상이
  • gnb, lnb, sub-menu 등 메뉴 구조 제각각
  • 하나의 선택자 규칙으로 모든 보건소 커버 불가능

🎯 크롤링 규칙 (CRAWL_RULES, TAB_SELECTORS)

해결 방법

각 보건소마다 개별 크롤링 규칙 정의

CRAWL_RULES 예시

CRAWL_RULES = [
    {
        "name": "동작구 건강관리청 LNB",
        "domain": "dongjak",
        "main_selector": ".left-area .left-mdp1 > li > a",
        "sub_selector": [
            ".left-mdp1 > li.on > ul > li > a",
            ".tab-list li a",
        ],
    },
    {
        "name": "서울시 임신출산정보센터",
        "domain": "seoul-agi",
        "single_page": True,
        "menu_container": "#content-menu",
        "main_selector": ".menu-item > .menu-link",
        "sub_selector": ".menu-sub-item > a.menu-sub-link",
    },
]

TAB_SELECTORS 예시

TAB_SELECTORS = [
    ".tabmenu ul li a",        # 강남구
    ".tab-list li a",          # 동작구
    ".tab-nav ul li a",        # 강북구
    ".tab_panel ul li button", # 성북구
    ".content-tab ul li a",    # 양천구
]

추가 설정

설정 항목 설명
HTTP 설정 각 보건소별 요청 헤더
쿠키 설정 세션 유지를 위한 쿠키
SSL 설정 인증서 검증 옵션

⚠️ 링크 중복 수집 문제

문제 상황

크롤링을 위한 초기 항목(링크) 수집 단계에서 아래와 같은 문제가 발생했습니다.

문제:

  • dep1(모자보건) → dep2(임신준비 지원) → dep3의 링크가 모두 동일
  • 나중 링크 무시 → 제목은 dep1, 내용은 dep3로 수집되는 불일치 발생

해결 방법

depth_scores 기반 제목 선택 전략

DISTRICT_CONFIGS: Dict[str, Dict[str, Any]] = {
    "은평구": {
        "strategy_class": EPStrategy,
        "filter_text": "사업안내",
        "output_dir": "app/crawling/output/은평구",
        "max_workers": MAX_WORKERS_DEFAULT,
        "depth_scores": {2: 5000, 3: 10000, 4: 20000},  # 구체성 점수
    },
    "용산구": {
        "strategy_class": YongsanStrategy,
        "filter_text": None,
        "output_dir": "app/crawling/output/용산구",
        "max_workers": MAX_WORKERS_DEFAULT,
        "depth_scores": {1: 5000, 2: 10000},
    },
}
핵심 아이디어 설명
depth_scores depth가 증가할수록 점수 증가
최종 제목 선택 가장 높은 depth의 제목 사용

🧹 링크 수집 전처리

문제점

보건소에서 제공하는 정보들 중에 취지에 맞는 정보도 있었지만 그렇지 않은 불필요한 정보들도 존재했습니다.

중구 - 보건사업 > 위생 > 공중위생 업종의 정의 서초구 - 생애주기건강관리 > 건강도시서초 > 담배연기 제로 서초 > 금연 FAQ > 흡연과태료 의견제출 및 이의제기 안내

해결 방법

키워드 블랙리스트 기반 필터링

KEYWORD_FILTER = {
    "blacklist": [
        # 비건강/의료 관련 키워드
        "교육", "캠페인", "건강도시", "조사",
        "동물", "사진", "기관정보", "자료실", "정의",
    ]
}

⚡ 크롤링 속도 개선

기존 워크플로우

단계 작업 방식
1 초기 항목(링크) 수집 순차 처리
2 초기 항목 저장 선택 사항
3 항목 처리 및 구조화 ❌ 순차 처리
4 결과 저장 -
5 요약 출력 -
6 완료 후 처리 -

문제점

수집된 링크에 대해서 하나씩 항목 처리 및 구조화(process_items_for_workflow)를 진행하고 있었기 때문에 매우 느린 속도를 보였습니다.

성능 측정 (Before)

측정 항목
처리 항목 수 48개
워크플로우 전체 288.85초
평균 페이지 처리 4.78초

문제: 최대 160개 항목 보건소는 20분 이상 소요

개선 방법

ThreadPoolExecutor 기반 병렬 처리

concurrent.futures.ThreadPoolExecutor을 이용하여 링크들을 병렬로 처리하도록 변경했습니다.

성능 측정 (After)

측정 항목 Before After 개선율
워크플로우 전체 288.85초 81.18초 3.5배
평균 페이지 처리 4.78초 4.91초 유사

🤖 LLM 기반 데이터 구조화

목적

원본 텍스트에서 지원 대상지원 내용을 요약하여 사용자에게 제공


🔄 2단계 파이프라인

전체 흐름

원본 HTML (raw_text)
    ↓
텍스트 전처리
    ↓
정제된 텍스트 (최대 20만 자)
    ↓
1단계: 요약 생성
    ↓
구조화된 데이터 (title, support_target, support_content)
    ↓
2단계: 품질 평가
    ↓
최종 데이터 + 품질 점수 (eval_target, eval_content)

🧪 텍스트 전처리

1️⃣ HTML 노이즈 제거

본문 HTML에서 불필요한 요소들은 제외하고 메인 컨텐츠를 찾아 정책 정보만 추출하게 합니다.

제거 대상 요소

카테고리 선택자
네비게이션 nav, header, footer, .sidebar, .menu
광고 .ad, .advertisement
스크립트 script, style, noscript
팝업 .cookie-banner, .popup

메인 컨텐츠 탐색

for selector in [
    "main", "#content", "#main", ".content",
    ".main-content", ".contentArea", ".content-area",
    "article", ".article", "[role='main']",
]:
    content_area = soup_copy.select_one(selector)
    if content_area:
        break

2️⃣ 테이블 추출 및 포맷팅

정책 정보가 테이블(표) 형태로 제공되는 경우가 많아, LLM이 조금이라도 이해하기 쉽도록 테이블을 별도로 추출하여 [표 시작] ~ [표 끝] 형태로 포맷팅합니다.

포맷팅 예시

for table in content_area.find_all("table"):
    table_lines = ["[표 시작]"]
    # 헤더 추출
    headers = [th.get_text(strip=True) for th in table.find_all("th")]
    if headers:
        table_lines.append(" | ".join(headers))
        table_lines.append("-" * len(" | ".join(headers)))
    # 행 추출
    for row in table.find_all("tr"):
        cells = [cell.get_text(strip=True) for cell in row.find_all(["td", "th"])]
        if cells:
            table_lines.append(" | ".join(cells))
    table_lines.append("[표 끝]\n")

📝 1단계: 요약 생성

기능

항목 내용
목적 비정형 텍스트에서 구조화된 정보 추출
입력 정제된 HTML 텍스트 (최대 20만 자), 제목 힌트
출력 title, support_target, support_content

LLM 출력 스키마

class _LLMSummary(BaseModel):
    title: Optional[str] = None
    support_target: str
    support_content: str

프롬프트 핵심 규칙

규칙 설명
필드 분리 support_target ↔ support_content 분리
정보 정확성 원문에 없는 조건/수치 생성 금지
대상 정보 없음 support_target='정보 없음'

📊 2단계: 품질 평가

기능

항목 내용
목적 요약문의 품질 점수 부여
입력 요약된 지원 대상, 요약된 지원 내용
출력 eval_target (110), eval_content (010)

LLM 출력 스키마

class _LLMEval(BaseModel):
    eval_target: int = Field(ge=1, le=10, description="지원 대상 품질")
    eval_content: int = Field(ge=0, le=10, description="지원 내용 품질")

평가 기준

eval_target (지원 대상)

점수 기준
1 정보 없음
2 일반 거주 조건 (지역주민/서울시민)
3 단일 정성 조건 (청년/노인/장애/임산부)
4 거주 조건 + 단일 정성 조건
5 단일 정량 조건 (소득/병명/기간)
6 정성 조건 2개
7 정량 + 정성 복합조건
8 다중 복합조건 or 정성 조건 4개
9 명시적 행정기준 포함 복합조건
10 예외 조항 포함 복합조건

eval_content (지원 내용)

점수 기준
0 지원내용 없음
2 모호한 서술 ('지원합니다' 등)
4 항목은 있으나 금액/기간/횟수 미기재
6 금액/기간/횟수 중 1개 이상 구체 정보 포함
8 지원항목 다수 + 절차/제외조건 등 복수 요소
10 금액/횟수/기간/절차/예외 모두 구체적으로 명시