Selenium과 BeautifulSoup으로 네이버 클라우드 플랫폼 가이드 정보 추출하기

Selenium과 BeautifulSoup으로 네이버 클라우드 플랫폼 가이드 정보 추출하기

이번에 DevOps 스터디를 진행하면서, 네이버 클라우드 플랫폼의 여러 강의들을 불러와서 분야 별로 노션에 정리해야 하는 일들이 있었다. 사실 수동으로 작업하기에 그리 만만한 양은 아니어도 불가능할 정도의 일은 아니긴 한데, 어차피 NCP에서 강의들은 계속해서 업데이트될 것이고, DevOps 스터디 트랙은 이번 한 번만 하는 게 아니라, B612 운영하면서 계속될 예정이니 이참에 반 자동화하기로 했다.

페이지 분석하기

가장 먼저 확인해야 할 것은 페이지 분석하기이다. 우리가 찾는 것은 네이버 클라우드 플랫폼 '쉬운 시작 가이드'이다.

쉬운 시작 가이드에 들어가면 현재 제공되는 가이드의 목록들이 보인다. 그리고 검은색 목록 버튼의 '분류' 버튼을 누르면, 강의들을 분야와 주제에 맞게 분류해 놓은 모습을 볼 수 있다. 해당 분류 탭에서 예를 들어 'Container'라고 선택하면, 'Container' 탭에 있는 'Container Registry', 'Kubernetes Service' 분류를 갖고 있는 가이드들이 등장하는 식이다.

그렇다면 이제 가이드 페이지를 확인해 보자. 눈앞에 보이는 'Linux 서버 생성'에 들어가 보자.

다른 건 다 제쳐두고, 한 가이드에는 모두 공통적으로 필수 항목 세션 아래 프로젝트 구성시 필요 경험치와, 기술 항목 세션 아래 여러 기술 항목들이 존재함을 볼 수 있다. 이 기술 항목들이 가이드를 분류하는 항목들이며, 내가 원하는 것은 이 정보를 추출하는 것이다.

한편 url의 구조는 https://www.ncloud.com/guideCenter/guide/<page_number> 이런 형식이다. 예를 들어 https://www.ncloud.com/guideCenter/guide/1 이런 식으로 해서 뒤에 숫자가 늘어난다. 그래서 제일 간단한 방법은 숫자를 늘려가면서 페이지를 찾는 것이다. 페이지 번호가 없을 때는 (해당 가이드가 삭제된 페이지일 수도 있고, 그다음 page_number에 또 페이지가 있을 수 있으므로) 그다음 page_number로 넘어가며 찾아보면 된다. 이후에 연속으로 한 다섯 번 정도 없는 페이지가 나오면, 그때는 탐색을 종료하면 된다.

이 방법도 좋은데, 단점으로는 만약에 내가 다섯 번 정도 연속으로 탐색했는데 계속해서 페이지가 존재하지 않아서 탐색을 종료했는데, 막상 실제로는 그다음 페이지에 남아있는 가이드들이 있을 수 있다(그럴 일은 없을 수도 있겠지만). 그래서 그냥 https://www.ncloud.com/guideCenter/guide 이 페이지에서, 전체 가이드 목록을 불러오기로 했다.

전체 가이드 리스트 불러오기

페이지 분석하기

전체 가이드 목록을 불러오기 위해서, https://www.ncloud.com/guideCenter/guide 페이지를 분석해 보자. 이를 위해서 적합한 소프트웨어 하나가 있다. Insomnia라는 애플리케이션인데, REST API Request를 보내고 이에 대한 Response를 즉각적으로 받아볼 수 있다.

위에 insomnia의 기본적인 모습이다.

위와 같이 원하는 url에 http request를 보내면, 그에 대한 response를 볼 수 있다. GET 모드로 설정하고 send 버튼을 누르면

해당 페이지의 소스를 볼 수 있다.

우리가 원하는 정보를 찾았다. "list-item"이라는 <li> class 안에 우리가 원하는 정보가 들어 있다. 그리고 <a href="/guideCenter/guide/4"> 이런 식으로 상대적인 url을 찾을 수 있다.

이를 이용해서 전체 가이드 url 목록을 불러오는 파이썬 함수를 짜면

def get_guide_links(guide_list_url, base_url="https://www.ncloud.com"):
    response = requests.get(guide_list_url)
    soup = BeautifulSoup(response.text, "html.parser")

    guide_links = []
    for link in soup.find_all("li", {"class": "list-item"}):
        url = link.find("a").get("href")
        guide_links.append(base_url + url) # base_url + 상대 url 더해야 정상적인 절대 경로가 나옴

    return guide_links

위와 같이 짤 수 있다. 잘 동작하는지, jupyter 노트북을 통해서 확인해 보자.

오 예! 잘 작동한다!

Jupyter Notebook을 쓰는 이유

여기서 잠깐 Jupyter Notebook을 사용하는 이유는 특별한 이유가 있는 것은 아니고 원하는 정보를 단계적으로 찾기 위해서이다. 이때 Python Shell을 사용할 수도 있는데, 그보다는 Jupyter가 더 편리하므로 사용했다. 특히 이제는 VS Code에서도 Jupyter Notebook을 지원하므로, 일반 Python Shell을 사용하는 것보다 더 편리할 수 있다.

가이드 정보 추출하기

이제 모든 url을 순회하면서 정보를 추출하면 된다. 여기서부터 본 게임이다.

페이지 분석하기

이제 다시 Insomnia를 사용해서, 가이드 페이지를 긁어와 보자. 가장 먼저 https://www.ncloud.com/guideCenter/guide/4 페이지를 분석해 보자.

그런데 문제가 있다. 위 링크의 소스를 보면 알겠지만, 우리가 찾고자 하는 정보가 없다! 아무리 검색을 해보거나 눈으로 찾아봐도, 원하는 정보 - 경험치, 기술 항목과 같은 정보가 없다. 이는 실제 페이지에서는 javascript를 통해서 정보를 따로 불러오기 때문이다. 이를 해결하기 위해서는, selenium을 사용해야 한다.

selenium 사용하기

selenium은 웹 브라우저를 제어할 수 있는 라이브러리이다. 이를 사용해서, 웹 브라우저를 제어하고, 원하는 정보를 불러올 수 있다. 이를 위해서는 selenium을 설치해야 한다.

pip install selenium

Selenium을 사용하는 자세한 방법은 공식 문서를 참고하자.

Jupyter Notebook을 통해서, 차근차근 Selenium을 통해서 정보를 받아오는 과정이 필요하다.

from selenium import webdriver
from bs4 import BeautifulSoup
from selenium.webdriver.support.ui import WebDriverWait

# 크로미움 웹 드라이버 생성
driver = webdriver.Chrome()

# 탐색할 가이드 링크
guide_link = 'https://www.ncloud.com/guideCenter/guide/4'

# 웹 드라이버로 가이드 링크 접속
driver.get(guide_link)

# before-start.stepNav.section 이라는 클래스가 나타날 때까지 최대 10초 동안 기다림
WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.CSS_SELECTOR, '.before-start.stepNav.section')))

soup = BeautifulSoup(driver.page_source, 'html.parser')

# store file
with open('guide.html', 'w') as f:
    f.write(soup.prettify())

# 웹 드라이버 종료
driver.quit()

간단히 Jupyter Notebook으로 위 코드를 실행하는 것만으로도, 완전히 로드된 상태의 페이지를 확인할 수 있다. 참고로 특정 css가 나타날 때까지 기다리고 있는데, 이건 결과론적인 이야기이다. 실제로는 html 파일 내부에 어떤 정보가 들어 있는지 알 수가 없으므로, 말 그대로 10초 동안 기다리고 나서 html 파싱을 해야 한다.

guide.html 파일로 저장해 놨으므로, 여기서 우리가 원하는 정보를 찾아보자.

찾았다. 이러면 거의 다 됐다.

<div class="before-start stepNav section" data-v-7a181e36="">
    <h3 data-v-7a181e36="">
    시작하기 전에
    </h3>
<!-- ... -->
        <div data-v-7a181e36="">
        <p class="bsbox-title" data-v-7a181e36="">
        프로젝트 구성 시 필요 경험치
        </p>
        <p class="bsbox-content" data-v-7a181e36="">
        초급
        </p>
        </div>
    </div>
    <hr class="mb-30" data-v-7a181e36=""/>
    </div>

<!-- ... -->

    <div data-v-7a181e36="">
        <h4 data-v-7a181e36="">
         기술 항목
        </h4>
        <div class="before-start-box" data-v-7a181e36="">
         <div data-v-7a181e36="">
          <p class="bsbox-title" data-v-7a181e36="">
           Server
          </p>
          <p class="bsbox-content" data-v-7a181e36="">
           물리적인 서버 자원을 별도로 구매하지 않고 클라우드 환경에서 빠르게 생성해 사용한 만큼만 비용을 지불하는 서비스
          </p>
         </div>
         <div data-v-7a181e36="">
          <p class="bsbox-title" data-v-7a181e36="">
           ACG
          </p>
          <p class="bsbox-content" data-v-7a181e36="">
           서버 그룹에 대한 네트워크 접근을 제어 및 관리할 수 있으며 서버 그룹별로 방화벽 규칙을 설정할 수 있는 서비스
          </p>
         </div>

<div class="before-start stepNav section"> 안에 두 개의 <div class="before-start-box"> 태그가 존재한다. 하나는 경험치 정보를 포함하고 있고, 다른 하나는 기술 항목에 대한 정보를 포함하고 있다.

첫 번째 <div> 태그의 경우, <p class="bsbox-title"> 태그 안에 경험치라는 단어가 들어가 있다. 그리고 <p class="bsbox-content"> 태그 안에 초급이라는 단어가 들어가 있다. 이를 통해서 경험치 정보를 얻을 수 있다.

# before-start.stepNav.section css 찾기
before_start_section = soup.find('div', {'class': 'before-start stepNav section'})

# bsbox-title 태그 찾기
bsbox_titles = before_start_section.find_all('p', {'class': 'bsbox-title'})

# 찾은 bsbox-title 태그 안에서, 경험치라는 단어가 들어간 태그 찾기
for title in bsbox_titles:
    if '경험치' in title.text:
        # 경험치라는 단어가 들어간 태그의 다음 태그가 바로 '초급', '중급' 등의 경험치 정보를 담고 있다.
        # 이때 다음 태그는 bsbox-content 태그로 찾아야 한다.
        experience_level = title.find_next_sibling('p', {'class': 'bsbox-content'}).text.strip()

        # 찾았으니깐 break
        break

두 번째 <div> 태그의 경우, 먼저 <h4>기술 항목</h4>로 제목이 나오고, <p class="bsbox-title"> 태그 안에 Server, ACG 등의 기술 항목들이 들어가 있다. <p class="bsbox-content"> 안에 있는 정보는 필요치 않으므로 무시하고, <p class="bsbox-title"> 안에 있는 정보만 가져오면 된다.

tech_items_section = before_start_section.find('h4', string='기술 항목').find_next_sibling()
tech_items = [item.text.strip() for item in tech_items_section.find_all('p', {'class': 'bsbox-title'})]

 

한편 번외로, 제목은 search_title이라는 <h2> 태그를 찾으면 된다.

# 제목
name = soup.find('h2', {'class': 'search_title'}).text.strip()

완성된 함수의 모습이다.

def get_guide_info(driver, link):
    driver.get(link)
    WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.CSS_SELECTOR, '.before-start.stepNav.section')))
    soup = BeautifulSoup(driver.page_source, 'html.parser')

    # 제목
    name = soup.find('h2', {'class': 'search_title'}).text.strip()

    before_start_section = soup.find('div', {'class': 'before-start stepNav section'})
    bsbox_titles = before_start_section.find_all('p', {'class': 'bsbox-title'})

    for title in bsbox_titles:
        if '경험치' in title.text:
            experience_level = title.find_next_sibling('p', {'class': 'bsbox-content'}).text.strip()
            break

    tech_items_section = before_start_section.find('h4', string='기술 항목').find_next_sibling()
    tech_items = [item.text.strip() for item in tech_items_section.find_all('p', {'class': 'bsbox-title'})]

    return {
        'name': name,
        '프로젝트 구성시 필요 경험치': experience_level,
        '기술 항목': tech_items,
        'url': link
    }

결과물

{'name': 'Cloud DB for MSSQL 사용', '프로젝트 구성시 필요 경험치': '초급', '기술 항목': ['Server', 'ACG', 'SSMS', 'Cloud DB for MSSQL'], 'url': 'https://www.ncloud.com/guideCenter/guide/11'}

썩 잘 나왔다. 최종적으로 main 함수를 완성해 보자.

from selenium import webdriver
from ncp import get_guide_info, get_guide_links
from utils import save_to_json


def main():
    guide_list_url = 'https://www.ncloud.com/guideCenter/guide'
    guide_links = get_guide_links(guide_list_url, base_url='https://www.ncloud.com')

    driver = webdriver.Chrome()

    guides_info = []
    for link in guide_links:
        print(f"Getting guide info from {link}")
        guide_info = get_guide_info(driver, link)
        guides_info.append(guide_info)

    # 웹 드라이버 종료
    driver.quit()

    save_to_json(guides_info, 'assets/guides_info.json')


if __name__ == "__main__":
    main()

데이터 추출해서 저장하기 (결과물)

 

빨리 감기 한 결과이긴 한데, 실제로도 1-2분이면 모두 결과물이 추출된다.

[
    {
        "name": "Cloud Functions로 액션 실행",
        "프로젝트 구성시 필요 경험치": "초급",
        "기술 항목": [
            "Cloud Functions"
        ],
        "url": "https://www.ncloud.com/guideCenter/guide/4"
    },
    {
        "name": "Linux 서버 생성",
        "프로젝트 구성시 필요 경험치": "초급",
        "기술 항목": [
            "Server",
            "ACG",
            "PuTTY"
        ],
        "url": "https://www.ncloud.com/guideCenter/guide/1"
    }
]

이런 식으로 추출된다.

결과물은 추출했으니, 이제 남은 것은 노션에 연동해서 이를 올리는 작업이다. 이 작업은 다음 포스트에서 다루도록 하겠다.

 

Behind Story

글에서는 생략됐지만 실제 과정은 꽤 깔끔하지 않았다. 여러 가지 문제점들이 있었다. 예를 들자면 바로 프로젝트 구성시 필요 경험치를 찾는 일이었다. 문제가 뭐였냐면, 어떤 페이지에서는 "프로젝트 구성시 필요 경험치"라고 적혀 있었는데, 또 어떤 페이지에서는 "프로젝트 구성 시 필요 경험치"라 적혀 있었다. 원래는 "프로젝트 구성시 필요 경험치"라는 문자열 자체를 찾았었는데, 이후에 "경험치"라는 단어가 포함된 문자열을 찾는 방식으로 바꾸어서 해결하긴 했다.

 

비슷하게 띄어쓰기 공백이 뒤에 포함되어 있는 걸로 못 찾기도 했고, 또 완성된 html을 불러오는 거 자체가 문제가 발생하기도 했다.

 

또 문제가 뭐냐면 위와 같은 코드는, 만약에 페이지 구조가 변경됐을 때 사용하지 못하는, 일회용의 스크립트에 가깝다는 단점도 있기는 하다. 스크래핑이 참 유용한 것 같은데, 나중에 제대로 된 크롤링/스크래핑을 배워보고 싶다.