TV 편성표를 크롤링하며 Selenium의 기초부터 json으로 내보내기까지!

TV 편성표를 크롤링하며 Selenium의 기초부터 json으로 내보내기까지!

Python으로 Selenium과 json을 이용하기

·

8 min read

  • 블로그를 이전하며 2022년 03월 25일 작성하였던 글을 재작성했습니다.

  • 현재 (2023년 7월 7일) 기준 사이트의 구조가 바뀌어 제대로 동작하지 않을 수 있습니다. 방법의 내용만 참고하시길 바랍니다.

  • 현재 (2023년 10월 31일) 기준 사이트 크롤링 가능 여부가 Disable로 바뀐 것을 확인했습니다. 이에 따라, TV 편성표 정보가 있는 사이트로 연결된 모든 링크를 삭제했으며 해당 사이트에 대한 정보도 익명 처리로 바꾸었습니다. 크롤링 실습 이전에 가능 여부를 반드시 확인하시길 바랍니다.

  • 이 내용에 대해 유효한 글을 작성하면 링크를 추가해두겠습니다.


개요

이 글은 TVList 프로젝트를 하며 채널 정보를 가져오면서 배운 것들을 적어둔 글이다. 학교에서도 기초적인 것은 배웠으나 실제로 내가 원하는 정보를 도출하기 위해 Python의 기능도 꽤 많이 활용했다. 그리고 버전이 바뀌면서 내가 참고했던 자료들대로 따라하니 오류가 났다. 그래서 새로운 버전에 맞춘 내가 쓰는 도움말이다. 여기에서는 설치 이후, Selenium을 통해 코드를 작성하고 결과를 얻는 것에 대해서만 서술한다. 이전 버전의 Selenium을 다루지만 잘 설명된 글이 있다. 블로그 Forio Learning에서 설치부터 기초적인 사용 방법까지 잘 설명해주셨다. Python Selenium 사용법 [파이썬 셀레늄 사용법, 크롤링]에서 확인할 수 있다.

이 글에서 사용되는 배경 사항은 다음과 같다.

  • 크롤링은 국내 통신사 중 채널 정보를 공개하는 사이트에서 진행한다.

  • driver로 Google Chrome을 사용한다. (버전은 ChromeDriver 94.0.4606.41)

  • 개발 환경은 Jupyter Notebook을 사용했다.

Selenium의 기본 사용 체계

  1. selenium을 import 한다.

  2. driver를 설정하고, driver에 크롤링할 url을 get한다.

  3. 크롤링할 내용을 확인하고 적절한 방법으로 크롤링한다.

다른 파이썬 모듈과 같이 위와 같은 형태로 사용한다고 생각하면 된다. 적절한 방법이란, 자신이 원하는 정보를 가장 효율적으로 가져올 방법이라고 생각한다. 나는 그래서 CLASS_NAME을 포함해 서너가지의 특징을 사용했다. 자신이 사용할 적절한 방법을 찾아보자.

상세 과정 살펴보기

우선, 나의 목표는 TV 편성표에서 채널 목록id로 사용할 채널 번호카테고리를 가져와 각 채널에 대한 정보로 만들어 json 파일로 export하는 것이었다. 그리고 목표 달성을 위한 방법을 순서대로 아래에 적어두었다.

1. 필요한 것들을 import 하고 기본 설정 하기

pip install selenium
import selenium
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By

Selenium과 함께 webdriver, keys, by를 별도로 import 해준다.

#Chrome 웹 드라이버를 driver에 할당
driver = selenium.webdriver.Chrome()
#driver의 url 설정
driver.get(url='TV_편성표_사이트_링크')

driver = selenium.webdriver.Chrome()에서 크롬으로 새 창이 열릴 것이다. url 변화 등이 일어나면 해당 창에서도 변화가 똑같이 일어난다.

2. 크롤링 진행하기

얻으려는 정보 세 가지 (채널 이름, 번호(id), 카테고리)를 리스트로 저장한다. 이 때 중요한 것이 있다. find_element()find_elements()를 사용해 우리가 원하는 정보를 받아온다는 것이다. 우리가 선택할 객체가 여러 개인 경우(방송 목록, 채널 목록 등) find_elements()를 사용하면 list로 한 번에 가져올 수 있다. 그런데 주의할 점 이 있다. __find_elements()를 사용해서 얻어지는 것은 web element이기 때문에 이를 변환해주어야 한다. 코드를 천천히 보면서 자세히 알아보겠다.

chlist=[] #채널 목록을 담을 리스트
atts=[] #채널의 속성을 담을 리스트

driver.implicitly_wait(time_to_wait=3)
# 카테고리 부분을 tab이라는 요소에 할당.
tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[1]/a')

implicitly_wait는 페이지가 로딩될 때 기다릴 시간을 지정하는 것이다. Selenium의 wait는 종류가 여럿 있는데, 기본적으로 implicitly wait는 페이지가 로드 될 때까지의 최대 대기 시간 으로 driver를 열고 한 번만 설정한다. (로딩 되면 100초를 걸어둬도 다음 명령어로 넘어간다.) explicitly wait은 내가 원하는 특정 부분이 로드될 때까지의 최대 대기 시간 이다. 그래서 원하는 요소를 기다릴 필요가 있을 때 사용하는 방식이다. 고정 시간을 대기하도록 하고 싶으면 time의 sleep을 import하여 사용해야한다.

그리고 tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[1]/a')라고 작성한 부분은 쉽게 얘기하면 tab에 Xpath라는 방법으로 찾은 요소를 지정하겠다는 뜻이다. 이 글에서는 CLASS_NAME과 Xpath를 사용한다. 글 제일 아래에 직접 사용하지 않은 find_elemnet에 사용할 수 있는 속성에 대해 정리해두었다.

찾을 요소의 Xpath 값이 두 번째 인자로 들어가는 긴 url같은 요소다. Xpath 값을 구하는 건 엄청 쉽다. Chrome에서 F12를 눌러 개발자 도구를 연 뒤 왼쪽 상단의 요소 선택 도구 (Select a element in page)를 눌러서 요소를 선택하거나 직접 코드를 봐도 좋다. 요소 선택 도구의 단축키는 Ctrl+Shift+C이다. 요소를 찾으면 Xpath를 쉽게 얻을 수 있다. 요소를 나타내는 코드에 마우스를 가져다 대고 우클릭해서 컨택스트 메뉴를 연다. 그리고 'Copy'의 'Copy Xpath'를 선택하면 해당 요소의 Xpath가 복사된다.

#카테고리 1부터 9까지 반복.
for i in range(1,10):
    #Xpath로 요소들을 찾아 ch에 넣는다. (카테고리에 해당하는 채널 목록)
    ch = driver.find_elements(By.XPATH,'//*[@id="tab0%d"]/div[1]/ul/li/a'%i)
    temp = [x.text for x in ch] #string으로 바꾸어 저장함.
    #onclick 속성에 해당하는 값을 att에 할당.
    att = [y.get_attribute("onclick") for y in ch]
    #채널 목록 배열과 속성 목록에 추가하기.
    chlist += temp
    atts += att
    #채널의 카테고리를 다음으로 넘긴다.
    tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[%d]/a'%i)
    tab.send_keys(Keys.ENTER)  

#카테고리 10부터 20까지 반복.
for i in range(10,21):
    #위와 같은 동작.
    ch = driver.find_elements(By.XPATH,'//*[@id="tab%d"]/div[1]/ul/li/a'%i)
    temp = [x.text for x in ch]
    att = [y.get_attribute("onclick") for y in ch]
    chlist += temp
    atts += att
    tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[%d]/a'%i)
    tab.send_keys(Keys.ENTER)

크롤링을 진행하는데 위와 같이 1부터 9까지, 10부터 20까지 나눈 이유는 간단하다. [@id="tab0%d"]부분을 개발자 도구로 보면 1부터 9까지의 값은 0이 붙어있다. tab03, tab09 이런 식이다. 그런데 전부 다 1~9까지의 방식으로 하면 10부터는 010이 되어버리기 때문에 분리한 것이다.

ch에는 채널 목록에 해당하는 값들을 저장한다. 그런데 web element로 저장 되므로 temp에 한 번 string 값으로 변환하여 저장한 뒤 chlist에 넣어준다. 그리고 속성 값은 ch라는 web element의 속성 중 onclick에 명시되어있는 값을 배열로 저장하겠다는 것이다. 이건 string 형태의 배열로 저장되기 때문에 별도의 변환 과정은 거치지 않는다.

get_attribute()를 보고 눈치 챘겠지만, 인수로 지정한 속성의 값을 반환하는 함수 다. 이걸 활용한 이유는 웹페이지 내에 표기된 것에 채널 번호는 없기 때문이다. 이를 id를 활용할 것이고 카테고리 또한 정수 값으로 지정된 값이 있어 활용하기 위해 값이 저장된 속성을 따로 저장했다.

tab에 넣어둔 카테고리 값을 바꿔가며 채널 목록을 얻기 위해 tab.send_keys(Keys.ENTER)라고 작성했다. 이게 바로 동적 크롤링의 요소라고 볼 수 있는 것인데, 정적 크롤링에서는 javascript의 동작을 수행하도록 프로그래밍할 수 없기 때문이다. 이 동작을 통해 다음 카테고리에 해당하는 탭을 눌러 카테고리를 이동한 효과를 가진다. click()이라는 함수를 사용할 수도 있지만, 오류가 나기도 해서 내가 작성한 코드와 같이 적는 것을 추천한다.

반복문이 잘 돌고 있는지 혹은 현재 크롤링하는 페이지가 내가 원하는 페이지가 맞는지 알고 싶을 때는 print(driver.current_url)이라고 하여 현재 url을 확인할 수 있다. 이제 원하는 정보를 다 얻어왔으므로 내가 사용하기 편한 형태 혹은 저장하기 원하는 형태로 데이터를 바꾸어줘야 한다.

3. 정보 가공하기

no = atts.copy()
no = [x.strip("'""javascript:fn_view("")"",") for x in no]
genre = [x[:4] for x in no]
no = [x[7:] for x in no]
no = list(map(int, no))
genre = list(map(int, genre))

atts는 원래 형태를 보존하기 위해 copy() 후 가공을 진행했다. no라는 배열에 atts를 복사하고 strip을 통해 필요없는 문자열을 지웠다. 바로 세 번째와 네 번째 줄의 방법으로 실행해도 되지만, 가공하면서 직관적으로 값을 확인하기 위해 문자열을 지우는 단계를 거쳤다. strip을 통해 배열을 정리했다. 'javascript:fn_view(, ), ,를 적당히 지웠다. 목적이 가독성이었기에 전부 지울 필요가 없었으므로 replace가 아니라 strip을 사용했다.

javascript:fn_view('5100','14','','','') -> 5100','14

위와 같이 정리된 값에서 앞의 네 자리는 카테고리를 나타내는 값이었고 뒤의 두, 세자리는 id와 같이 고유한 값을 가졌다. 그래서 배열 genre에는 [:4]로 값을 넣고, 배열 no에는 7번째 값부터 나머지를 넣도록 했다. 그리고 둘 다 자연수로만 되어있으므로 map을 활용해 int로 변환했다.

json으로 내보내기

위의 과정에서 원하는 데이터 유형으로 정리했으니 이젠 json으로 내보내기 위해 dictionary로 만드는 과정을 거칠 것이다. 우선 json을 다룰 것이기에 json 모듈을 import 해준다.

import json

그리고 json은 dictionary형태로 저장하게 되는데, 나는 dictionary가 가득한 배열(무려 286개나 된다.)을 json 파일로 저장할 것이다.

channels = []
for i in range(len(chlist)):
    channels.append({
        "number" : no[i],
        "name" : chlist[i],
        "category" : genre[i],
        "link" : "",
        "description" : ""
    })

배열 channels를 선언하고 우리가 가진 채널개수만큼 반복해 append 해준다. 딕셔너리를 코딩 테스트 때 쓰다보면 굉장히 단순한 형태로 많이 썼었는데 이번에 이렇게 사용해보면서 딕셔너리의 유용성에 대해 다시금 느끼게 되었다. append를 할 때 아예 dictionary의 모든 정보를 넣어서 만들었다. 일단, number는 채널 번호이자 id로 쓰이게 될 정보다. (채널 번호라는 건 나의 추측이다. 채널에 고유한 번호를 붙이는 점이나 숫자 값이 너무나도 채널 번호와 유사했다...) 그리고 이름, 카테고리, 채널의 링크, 설명을 붙인다. 링크와 설명은 채널의 사이트가 있는 경우도, 없는 경우도 있으므로 보류하기로 결정했다. 애초에 편성표 내에서 사이트로 연결되는 정보는 없었다. 설명도 마찬가지다.

with open("ChannelData.json", 'w') as outfile:
    json.dump(channels, outfile, indent=4)

나는 json으로 내보내기 전에 한 번 print하여 확인해봤다. 원하는 대로 잘 되어있었다. open을 사용하여 저장할 파일 이름을 w(write)로 열어준다. 이 때 파일 이름이 저장 위치에 없다면, 새로 파일을 하나 만들어서 저장한다. 그리고 json.dump를 통해 파일을 저장한다. 인자는 순서대로 저장할 데이터, 파일 객체, 기타 설정 이다. 즉, channelsoutfileindent=4로 저장하겠다는 뜻이다. indent 값을 주게 되면 보기 좋게 정렬해준다. indent 설정이 없으면 print했을 때처럼 줄 넘김 없이 저장된다.

잘 저장되었는지 확인해보았다. 1878줄이라 100줄 정도만 gist에 옮겨두었다. 어떤 식으로 저장되는지 궁금하다면 참고하도록 한다.

결과 Gist: ChannelData.json

전체코드

Github Gist가 더 편한 사람들은 이 링크를 따라가면 Gist로 확인 가능하다.

pip install selenium
import selenium
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By

driver = selenium.webdriver.Chrome()
driver.get(url='TV_편성표_사이트_링크')

chlist=[]
atts=[]
driver.implicitly_wait(time_to_wait=3)
tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[1]/a')

for i in range(1,10):
    ch = driver.find_elements(By.XPATH,'//*[@id="tab0%d"]/div[1]/ul/li/a'%i)
    temp = [x.text for x in ch]
    att = [y.get_attribute("onclick") for y in ch]
    chlist += temp
    atts += att
    tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[%d]/a'%i)
    tab.send_keys(Keys.ENTER)  
for i in range(10,21):
    ch = driver.find_elements(By.XPATH,'//*[@id="tab%d"]/div[1]/ul/li/a'%i)
    temp = [x.text for x in ch]
    att = [y.get_attribute("onclick") for y in ch]
    chlist += temp
    atts += att
    tab = driver.find_element(By.XPATH, '//*[@id="contents"]/div[2]/div[1]/div[2]/ul/li[%d]/a'%i)
    tab.send_keys(Keys.ENTER)  

no = atts.copy()
no = [x.strip("'""javascript:fn_view("")"",") for x in no]
genre = [x[:4] for x in no]
no = [x[7:] for x in no]
no = list(map(int, no))
genre = list(map(int, genre))

import json

channels = []
for i in range(len(chlist)):
    channels.append({
        "number" : no[i],
        "name" : chlist[i],
        "category" : genre[i],
        "link" : "",
        "description" : ""
    })

with open("ChannelData.json", 'w') as outfile:
    json.dump(channels, outfile, indent=4)

find_element의 속성

By를 이용해 By.속성이름 식으로 사용한다. find_element(By.속성이름, 속성 값)의 형태로 사용할 수 있다.

속성이름설명
XPATHXpath 값을 지정하여 조회한다.
ID속성의 id 값으로 조회한다.
NAMEname 값으로 조회한다.
TAG_NAMEtag 값으로 조회한다.
CLASS_NAMEclass 값으로 조회한다.
LINK_TEXT연결된 링크의 값이 지정한 값이면 조회한다.
PARTIAL_LINK_TEXT링크의 일부에 포함되는 값을 지정하여 찾는다.
CSS_SELECTORCSS 요소를 지정하여 해당 요소이면 조회한다.

참고문헌