[문제 해결] Python BeautifulSoup: 웹사이트 내부 링크 및 앵커 텍스트 추출 완벽 가이드

웹사이트의 내부 링크 구조를 분석하는 것은 검색 엔진 최적화(SEO)와 사용자 경험(UX) 개선에 매우 중요합니다. 이는 사이트의 정보 아키텍처를 이해하고, 페이지 간의 흐름을 파악하며, 깨진 링크를 식별하는 데 도움을 줍니다. 하지만 HTML 페이지에서 모든 내부 링크를 효율적으로 추출하고, 그 앵커 텍스트와 최종 목적지 URL을 기록하는 과정은 생각보다 복잡할 수 있습니다.

특히 상대 경로 처리, URL 조각(fragment) 제거, 그리고 중복 링크 방지와 같은 문제에 부딪히기 쉽습니다. 이 가이드에서는 Python과 BeautifulSoup 라이브러리를 활용하여 이러한 난관들을 극복하고, 웹사이트의 내부 링크를 완벽하게 추출하고 정규화하는 방법을 상세히 설명합니다.

1. 문제 상황: 웹사이트 내부 링크 분석의 중요성

개발자들은 종종 특정 웹사이트의 내부 링크 구조를 분석하여 다음을 수행해야 할 필요성을 느낍니다:

  • SEO 관점에서 페이지 간의 링크 가중치 흐름을 이해합니다.
  • 사용자가 웹사이트 내에서 어떻게 이동하는지 파악하여 UX를 개선합니다.
  • 사이트 감사(Audit)를 통해 잘못된 링크나 불필요한 리디렉션을 찾아냅니다.

이러한 작업을 위해 HTML을 파싱하고 링크 정보를 추출하는 스크립트를 작성할 때, 다음과 같은 일반적인 질문에 직면하게 됩니다:

  • HTML에서 모든 <a> 태그를 어떻게 찾을까?
  • 상대 경로로 되어 있는 링크는 어떻게 절대 경로로 변환할까?
  • URL에 붙어있는 #section과 같은 조각(fragment)은 어떻게 제거할까?
  • 동일한 목적지를 가리키는 중복 링크를 어떻게 효율적으로 처리할까?

이러한 문제들을 해결하지 못하면 부정확하거나 불완전한 데이터로 인해 잘못된 분석 결과를 초래할 수 있습니다.

2. 핵심 과제: 상대 경로, URL 조각, 중복 링크 처리

내부 링크 추출 시 발생할 수 있는 주요 과제와 그 중요성은 다음과 같습니다.

2.1. 상대 경로(Relative URLs) 처리

대부분의 웹사이트는 내부 링크에 /about-us, ../products/item.html 등과 같은 상대 경로를 사용합니다. 이 링크들을 정확한 절대 URL (예: https://example.com/about-us)로 변환하지 않으면 링크의 실제 목적지를 식별할 수 없습니다. 기준 URL(Base URL)과의 조합이 필수적입니다.

2.2. URL 조각(Fragments) 제거

URL 조각은 페이지 내의 특정 섹션을 가리키는 데 사용되는 # 문자 뒤의 부분입니다 (예: https://example.com/page#section-1). 이는 동일한 문서 내의 다른 위치를 가리킬 뿐, 별개의 리소스가 아닙니다. 따라서 링크 분석 시 동일한 페이지로 간주하고 조각을 제거해야 중복을 피하고 정확한 페이지 집계를 할 수 있습니다.

2.3. 중복 링크 방지

한 페이지에 동일한 목적지 URL을 가리키는 여러 개의 링크가 있을 수 있습니다. 예를 들어, 텍스트 링크와 이미지 링크가 같은 페이지로 연결될 수 있습니다. 분석의 목적에 따라 이러한 중복을 제거하여 각 고유한 목적지 페이지에 대한 정보만 수집해야 합니다.

3. Python과 BeautifulSoup을 활용한 해결 방법

Python의 requests 라이브러리로 HTML을 가져오고, BeautifulSoup으로 HTML을 파싱하며, urllib.parse 모듈로 URL을 정규화하는 과정을 통해 위 문제들을 해결할 수 있습니다.

3.1. 필요한 라이브러리 준비

먼저 필요한 라이브러리들을 설치합니다:

pip install requests beautifulsoup4

3.2. HTML 콘텐츠 가져오기 및 파싱

requests를 사용하여 웹페이지의 HTML 콘텐츠를 가져오고, BeautifulSoup으로 파싱합니다.

import requests
from bs4 import BeautifulSoup

def fetch_html(url):
    try:
        response = requests.get(url)
        response.raise_for_status() # HTTP 오류가 발생하면 예외 발생
        return response.text
    except requests.exceptions.RequestException as e:
        print(f"Error fetching {url}: {e}")
        return None

3.3. 내부 링크 식별 및 정규화 로직

여기서는 urllib.parse 모듈의 urljoin, urlparse, urlunparse 함수를 사용하여 URL을 처리합니다. 특히 urlparse는 URL을 구성 요소(scheme, netloc, path, params, query, fragment)로 분리하고, urlunparse는 분리된 구성 요소를 다시 URL로 조합합니다.

from urllib.parse import urljoin, urlparse, urlunparse

def normalize_url(base_url, href):
    # 1. 상대 경로를 절대 경로로 변환
    absolute_url = urljoin(base_url, href)

    # 2. URL 조각(fragment) 제거 및 정규화
    parsed_url = urlparse(absolute_url)
    # fragment를 제외한 나머지 구성 요소로 새 URL 생성
    normalized_url = urlunparse(parsed_url._replace(fragment=''))
    
    return normalized_url

def is_internal_link(base_domain, url):
    # 링크가 동일한 도메인에 속하는지 확인
    parsed_url = urlparse(url)
    return parsed_url.netloc == base_domain and parsed_url.scheme in ['http', 'https']

3.4. 전체 코드 예시

위에서 설명한 모든 개념을 통합한 완전한 코드 예시입니다. 이 코드는 주어진 URL에서 모든 내부 링크와 해당 앵커 텍스트를 추출하고, 중복을 제거합니다.

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, urlunparse
import re

def extract_internal_links(url):
    html_content = fetch_html(url)
    if not html_content:
        return []

    soup = BeautifulSoup(html_content, 'html.parser')
    
    # 기준 도메인 추출 (내부 링크 여부 판단용)
    base_parsed_url = urlparse(url)
    base_domain = base_parsed_url.netloc

    # 고유한 링크 정보를 저장할 set (중복 방지)
    # (normalized_url, anchor_text) 튜플 저장
    internal_links_info = set()

    for a_tag in soup.find_all('a', href=True):
        href = a_tag['href']
        anchor_text = a_tag.get_text(strip=True) # 앵커 텍스트 추출 및 공백 제거
        
        # 1. URL 정규화 (절대 경로 변환 및 조각 제거)
        normalized_target_url = normalize_url(url, href)
        
        # 2. 내부 링크인지 확인
        if is_internal_link(base_domain, normalized_target_url):
            # 3. 쿼리 파라미터가 없는 경우를 위한 추가 정규화 (필요시)
            # 예를 들어, /page?param=1 과 /page?param=2 를 동일 페이지로 볼 경우
            # parsed_target = urlparse(normalized_target_url)
            # normalized_target_url = urlunparse(parsed_target._replace(query=''))

            internal_links_info.add((normalized_target_url, anchor_text))
            
    return sorted(list(internal_links_info)) # 정렬된 리스트로 반환

# --- 사용 예시 ---
if __name__ == '__main__':
    target_url = "https://www.google.com" # 분석하고 싶은 웹사이트의 URL을 입력하세요
    # 예시를 위해 실제 웹사이트를 사용했으나, 너무 많은 요청은 자제해주세요.
    # 테스트 목적으로는 작은 규모의 사이트나 로컬 HTML 파일을 활용하는 것이 좋습니다.

    print(f"'{target_url}'에서 내부 링크 및 앵커 텍스트 추출 시작...")
    links = extract_internal_links(target_url)

    if links:
        print(f"\n총 {len(links)}개의 고유한 내부 링크가 발견되었습니다:")
        for target_url, anchor_text in links:
            print(f"- 목적지: {target_url}, 앵커 텍스트: '{anchor_text}'")
    else:
        print("내부 링크를 찾지 못했습니다.")

코드 설명:

  • fetch_html(url): 주어진 URL의 HTML 내용을 가져옵니다. 오류 처리 로직이 포함되어 있습니다.
  • normalize_url(base_url, href): urljoin으로 상대 경로를 절대 경로로 바꾸고, urlparse와 urlunparse를 이용하여 URL 조각(#fragment)을 제거하여 URL을 정규화합니다.
  • is_internal_link(base_domain, url): 추출된 링크의 도메인이 기준 도메인과 일치하는지 확인하여 내부 링크 여부를 판단합니다.
  • extract_internal_links(url):
    • BeautifulSoup을 사용하여 모든 <a> 태그를 찾고 href 속성과 앵커 텍스트를 추출합니다.
    • normalize_url 함수를 호출하여 각 링크를 정규화합니다.
    • is_internal_link 함수로 내부 링크만 필터링합니다.
    • set 자료구조를 사용하여 (정규화된 URL, 앵커 텍스트) 튜플을 저장함으로써 중복을 자동으로 방지합니다.

4. 추가 고려사항 및 최적화 팁

  • 자바스크립트로 생성된 링크: 위 코드는 정적 HTML 내의 링크만 추출합니다. 만약 웹사이트가 JavaScript로 동적으로 링크를 생성한다면, Selenium이나 Playwright와 같은 헤드리스 브라우저 자동화 도구를 사용해야 합니다.
  • robots.txt 존중: 웹 스크레이핑 시 항상 해당 웹사이트의 robots.txt 파일을 확인하여 크롤링 정책을 준수해야 합니다. 너무 많은 요청을 보내 서버에 부담을 주지 않도록 주의해야 합니다.
  • 오류 처리 및 예외: 네트워크 문제, 잘못된 URL, HTML 파싱 오류 등 다양한 예외 상황에 대비하여 더 견고한 오류 처리 로직을 추가하는 것이 좋습니다.
  • 성능 최적화: 대규모 웹사이트를 분석할 때는 동시성 처리(비동기 요청, 스레딩/멀티프로세싱)나 데이터베이스를 활용한 결과 저장 등을 고려하여 성능을 최적화할 수 있습니다.
  • 다른 유형의 링크: CSS 파일, JavaScript 파일, 이미지 등 다른 리소스에 대한 링크도 분석해야 한다면, <link>, <script>, <img> 태그 등도 함께 파싱해야 합니다.
  • URL 쿼리 파라미터 처리: /page?id=1과 /page?id=2를 다른 페이지로 볼지, 아니면 쿼리 파라미터를 제거하고 /page로 동일하게 볼지는 분석 목적에 따라 달라집니다. 위의 코드에서는 기본적으로 쿼리 파라미터를 유지하지만, 필요한 경우 parsed_url._replace(query='')를 사용하여 쿼리 파라미터를 제거할 수 있습니다.

이 가이드를 통해 Python과 BeautifulSoup을 활용하여 웹사이트의 내부 링크를 효과적으로 추출하고 분석하는 데 필요한 지식과 코드 예시를 얻으셨기를 바랍니다. 이는 웹 개발, SEO, 데이터 분석 등 다양한 분야에서 유용하게 활용될 수 있습니다.

댓글 남기기