본문 바로가기
Scraping

[파이썬으로 웹스크래핑] 에러? 또 에러?! 셀레니움으로 막힘 없이 스크랩하기

by 돈민찌 2021. 9. 4.
반응형

내가 앞에서 쓴 글, 혹은 다른 곳에서 셀레니움을 동작하는 방법을 익힌 분들이 셀레니움이 특정 페이지에서 요소를 못찾거나 하는 이유로 프로그램 자체가 정지해버려 다시 처음부터 자료를 쌓아야 했다거나, 그런 오류(예외)를 막기 위해 try: except: 구문을 남발했더니 코드가 지저분해졌다-하는 이의제기를 해주셔서 글을 작성해본다.

첫번째, 보통 만나게 되는 오류들

브라우저 드라이버를 자유롭게 다루는 셀레니움은 그 기능만큼이나 다양한 오류를 개발자에게 가져온다.
그 중 "웹스크래핑"을 시도하는 개발자에게 자주 보이는 오류들을 선정해본다. (전체 익셉션은 여기에)

  • NoSuchElementException - "예? 그런 엘리먼트(태그) 없는데요?" 에러
  • ElementNotSelectableException - "예? 그 엘리먼트가 선택가능한 상태가 아닌데요?" 에러
  • ElementNotVisibleException - "예? 그 엘리먼트 제 눈에 안 보입니다" 에러
  • InvalidArgumentException - "주신 인수(아규먼트)가 잘못된 것 같아요" 에러
  • InvalidSelectorException - "주신 선택자가 좀 잘못된 것 같아요" 에러
  • NoSuchAttributeException - "엘리먼트에 그런 속성은 없대여 ㅇㅁㅇ" 에러
  • TimeoutException - "시키신거 찾아보려고 한참 기다려도 안나오더라구여 잉 일 안해" 에러
  • WebDriverException - "웹드라이버가 이상합니다" 에러
  • JavascriptException - "자바스크립트 명령문을 실행할 수가 없네여" 에러

물론 아주 대충 뜻만 나열한 것이고, 이 놈들을 분명히 자주 보게 되긴 할 것이다. 왜냐하면 우리가 스크랩하고자 하는 페이지들은 모두 같은 붕어빵 틀에다 반죽을 부었어도 팥이 꼬리에 많이 몰린 것도 있고 머리에 많이 몰린 것도 있고 슈크림도 있고 아 붕어빵 먹고싶다... 여튼 모두 똑같은 모습을 하진 않았기 때문이 첫번째 이유이고,
두번째는 웹페이지를 만든 사람 입장에서 크롤링이란게 영 반가운 존재가 아니기 때문에 걸어놓은 "로봇이 아닙니다"와 "자동문자를 입력하세요", 등의 봇 방지 프로그램과, 다양한 웹 빌드 방식으로 페이지마다 선택자의 이름(아이디나 클래스)을 조금씩 달리해서 이러한 불청객을 막는 방법들을 사용하고 있기 때문이다. 그러니까 1페이지를 보고 딱 맞게 짜놓은 코드가 2페이지에서는 그 태그나 그 클래스, 아이디를 찾지 못해서 TimeoutException이나 NoSuchElementException를 반환하는 경우가 많은거다. 당연히 마음대로 가져가지 말라고 해놓은 것을 억지로 가져갈 마음은 먹지 않는 것이 좋지만, 프로그래밍적으로 필요한 부분에서 스크랩을 시도하고 있다는 가정에서 내가 자주 사용하는 방법들을 말해보겠다.

 

아직도 코딩 안배웠어? 5만원 깎아줄께 형아만 믿어

 

두번째, 익셉션은 역시 try 구문으로..!

일단 이미 이 글을 보고 계신 분들은 이미 시도해봤을 방법일테지만, 그래도 조금 더 잘 쓰는 방법이 있다면 같이 공유하자는 뜻에서 글을 써 본다. 일단 파이참 같은 IDE에서도 try 구문에 exception을 붙이면 왜인지 모르게 밑줄이 그여진다. 마우스를 가까이 대 보면 Bare exception 코드를 쓰지 말라는 내용이다. 그러니까, 예외가 발생할 것을 어느 정도 감안하고 쓰는 try 구문에서도, 어떤 예외가 주로 발생할 것이며 그에 따라 어떻게 대처할 지 정도는 명시를 해두는 것이 좋다는 뜻 같다. 간단한(?) 해결방법으로는

from selenium import webdriver 
from selenium.common.exception import NoSuchElementException 
from selenium.webdriver.common.by import By 

driver = webdriver.Chrome() 
driver.implicitly_wait(5) 

for url in url_list: 
    driver.get(url) 
    try: 
    	element1 = driver.find_element(By.ID, "product-desc").text 
    except NoSuchElementException: 
    	print("Check your selector used in your find_by...or It's Not loaded")

요렇게 except 뒤에 해당 예외를 명시하고 그것에 해당하는 코드를 입력해 주면 된다. 당연히 비어있는 except 구문을 사용했을 때와 달리 IDE가 밑줄을 표시하지 않는다.
그런데, 이러한 try-except 구문이 거의 모든 라인에 반복된다거나, 한번의 url 접속에서 하는 모든 액션을 try구문 아래에 귀속시키는 분들을 꽤 많이 (입문반 챌린지를 도와드리다보니..) 접하게 되었다. 당연히 그런 식으로 모든 에러를 피해가려고 하는 코드는 당장 파이썬이 멈추는 일은 없겠지만 좋은 코드라고 할 수도 없고 좋은 데이터를 얻어내지도 못할 것이다.

세번째. 이런 방법은 어때? (사실 이게 메인)

이건 내가 고양이사료 관련 자료를 모을 때 자주 써먹은 방법인데, find_element(By.~~~).text 같이 단수 선택자를 쓰지 않고 find_elements(By.~~~) 해서 리스트 형태로 웹요소들을 담은 다음에 그 각각의 요소에 접근해서 text나 attribute를 따내는 것이다. 그러니까 무슨 말이냐면

from selenium import webdriver
# from selenium.common.exception import NoSuchElementException 
from selenium.webdriver.common.by import By 

driver = webdriver.Chrome() 
driver.implicitly_wait(5) 

for url in url_list: 
    driver.get(url) 
    # 보통 처음에 쓰는 방법! 
    result_what_i_want1 = driver.find_element(By.ID, "product-desc").text 
    # 내가 요즘 쓰는 방법!!! 
    elements = driver.find_elements(By.CLASS_NAME, "product") 
    result_what_i_want2 = "" # 빈 문자열을 만들거나 
    result_what_i_want = [] # 빈 리스트를 만들어도 좋다. 
    for elem in elements: # 문자열을 이어 붙이기 
    	result_what_i_want2 += elem.text
        # 리스트 형태의 경우 
        result_what_i_want.append(elem.text) 
        result_what_i_want3 = ", ".join(result_what_i_want)

단순하고 별거 없는 내용이긴 하지만, 일단 해당 엘리먼트를 찾지 못하더라도 빈 NodeList | ElementList를 갖게 될 뿐이고, 그 경우 그에 맞는 len(elements) 조건문을 삽입해주면 아무 문제가 없다. 당장 눈 앞에 "그거 못 찾겠어요!!!" 소리치지 않고 코드의 흐름이 끊기지 않고 이어지는 것이 매우 만족스럽다. 물론 코드가 다소 복잡해지고 길어보인다는 단점이 있다....지만 그 부분은 함수로 묶어버리면 되기 때문에 신경쓰지 않는다. 어떤 식이냐면

def get_text_by_xpath(xpath): 
    targets = driver.find_elements(By.XPATH, xpath) 
    if len(targets) == 0: 
    	return None
    elif len(targets) == 1: 
    	return targets[0].text.replace("\n", " ") 
    elif len(targets) > 1: 
        result_list = [] 
        for t in targets: 
            if t.text: 
                result_list.append(t.text.strip()) 
        if len(result_list) == 1: 
            return result_list[0] 
        else: 
            return result_list

이런 식이다. 어떻게 동작하는지 설명하자면, 보통 나는 CSS 선택자보다 상대적으로 짧고 보기에도 직관적인 XPATH를 자주 사용하는데, 이것을 문자열의 형태로 이 함수에 넘기면,
 - 일단 텅빈 결과물 리스트를 준비한다.
 - 타겟의 XPATH를 가지고 다수 엘리먼트를 선택(하려고) 한다.
 - 만약에 그 엘리먼트 리스트가 텅 비어있으면 그냥 None을 리턴한다.
 - 그게 아니라 한개만 들어있으면 (오히려좋아) 그 리스트의 첫번째이자 마지막 웹요소의 텍스트를 리턴한다.
 - 또 그게 아니라 두개 이상 들어있으면, 각 엘리먼트의 텍스트를 추출해 리스트 형태로 반환한다.
...라는 내용의 코드 치고는 좀 지저분해서 좋은 예시가 아닌 것 같지만, 전에 써둔 코드를 가져온거라 양해바랍니다.
어쨌든 예시에서 보여줬듯이, 타겟 선택자에 해당하는 요소가 하나 뿐만이 아닐때 이 코드는 각 엘리먼트의 텍스트(혹은 속성)을 리스트로 만들어 반환한다!! 우리가 원하는 타겟이 <ul>(혹은 ol) 태그 아래의 <li> 태그의 텍스트라면, 아니면 그 안에 들어있는 <a> 태그의 href라면, 그것을 추출한 리스트를 쉽게 얻어낼 수 있다. 물론 텍스트의 경우 그 상위 태그 (예시로 든 걸로 치자면 ul 태그 자체)를 선택해서 text를 뽑아내도 (\n이 들어있는) 텍스트를 잘 얻을 수 있겠지만... 후에 해당 텍스트를 가공하는 작업을 미리 줄이니까 좋지 않나 싶다. 암튼 이런 접근 방식으로 하려면 XPATH가 예를 들어,

원래타겟 = '//[@id="product"]/div/div[2]/ul' 의 text였다면, 
(asdf'\n'asdsa'\n'adasd'\n' 이런 식이겠지) 
바꾼타겟 = '//[@id="product"]/div/div[2]/ul/li[1]' 에서 
끝의 [1]을 똑 떼어주면 각 리스트의 텍스트를 가져올 수 있다.

조금 더 편하지...않나? 리스트나 테이블에 접근할 때 나는 이게 편하더라 아님말고

네번째 정공법, 오류가 안나면 되는거 아닌가? (주의: 자바스크립트 사용)

이 방법은 앞에 쓴 적 있는 explicitly_wait을 적극적으로 활용하고, 직접 ActionChain을 거는 대신 자바스크립트 코드로 해당 요소에 접근하는 방법을 쓰는 것인데, explicitly_wait은 셀레니움 기능탐구(2) 기다려! 대기하기 에서 많이 다뤘기 때문에(부족하면 구글링해야지 뭐... 공식 문서도 잘 나와있다.) 자바스크립트 코드를 사용하는 방법을 좀 말해보고자 한다. (???: 파이썬으로 스크랩하기라며 이게 뭐야!!!) 원래 세상이 그런거지...
일단 셀레니움이 요소에 접근하지 못해서 나타나는 익셉션 중에 대부분은 그 요소가 사용자가 스크롤을 내려야 로딩이 되는 요소거나, 혹은 정말 말 그대로 안보이는 상태에 있어서 나타나는 경우가 많은 것 같다. 그것을 피하기 위해 사용할 수 있는 것은 앞에 글에서 무한로딩을 설명하면서 보여줬던 코드들과 비슷하다.

# 이걸 파이썬 코드라 해야하긴 하는데... 뭔가 범위 밖에서 문제 내는것같네 
# 앞에서 사용한 코드를 "조금 더" 발전 시킨 것이다. 잘 짜여진 코드는 아닌 것 같은데 암튼 공유 

def get_text_by_xpath(xpath): 
    targets = self.driver.find_elements(By.XPATH, xpath) 
    if len(targets) == 0: 
    	return None 
    elif len(targets) == 1: 
    	driver.execute_script("arguments[0].scrollIntoView(false);",targets[0]) 
        return targets[0].text.replace("\n", " ") 
    elif len(targets) > 1: 
    	result_list = [] 
        driver.execute_script("arguments[0].scrollIntoView(false);", targets[-1]) 
        for t in targets: 
            if t.text: 
                result_list.append(t.text.replace("\n", " ")) 
        if len(result_list) == 1: 
            return result_list[0] 
        else:
            return result_list

셀레니움의 driver가 제공하는 메소드 중 execute_script() 메소드를 적극 활용하는 것이다. 위의 코드처럼 arguments의 메소드로 scrollIntoView()를 하면 (이 코드에서 직접적으로 파이썬으로 찾은 웹 요소를 넘겨줘도 된다는 점이 매우 좋다.) 찾은 요소가 있는 위치까지 자동으로 드라이버가 스크롤을 내린다. 괄호 안에 true를 주면 그 요소가 보일 때까지 (눈으로 볼 때 요소가 가장 아래에 걸치게)만 스크롤 되고, false를 주면 그 요소가 안보일때까지 스크롤을 내린다. 그러니까 단계적으로 스크랩핑을 해야할때, 위의 코드처럼 A라는 요소에 접근해 텍스트를 추출하고, 그 요소를 기준으로 그 요소가 안 보일때까지 스크롤을 내리고, 그럼 B 요소가 보이니까 B요소에 접근해 속성을 추출하고... 이렇게 함으로써 진짜 뭔가 제대로 스크랩핑을 하고 있다는 느낌이 들게 화면이 저절로 휙휙 움직인다.
이렇게 scrollIntoView 하는 자바스크립트 코드도 매우 사용하기 좋지만, 단순한 코드가 더 깔끔할 때도 있다.

// 현재의 스크롤 높이를 구하는 자바스크립트 코드 
return document.body.scrollHeight; 

// 특정 (절대) 위치로 스크롤하는 자바스크립트 코드 
window.scrollBy(0, 1000); // 첫번째가 x좌표, 두번째가 y좌표이다. 

// 특정 (상대) 위치로 스크롤하는 자바스크립트 코드 
window.scrollTo(0, 1000); // "현재 위치 기준으로" +x, +y 만큼 스크롤한다. 

// 1번 코드와 3번 코드 합치기 (현재 스크롤의 최대 높이만큼 스크롤한다.) 
window.scrollTo(0, document.body.scrollHeight);

이걸로 무한 스크롤을 구현할 수 있게 되는 것이다.

 def scroll_infinite(): 
    scroll_to_bottom = "window.scrollTo(0, document.body.scrollHeight);" 
    get_window_height = "return document.body.scrollHeight" 
    last_height = driver.execute_script(get_window_height) 
    while True: 
    	driver.execute_script(scroll_to_bottom) 
        time.sleep(3) 
        new_height = driver.execute_script(get_window_height) 
        if new_height == last_height: 
        	break 
        last_height = new_height

그러니까 내가 스크랩하려는 대상을 혹시 셀레니움이 못찾는 것 같다면, 혹은 로딩되지 않는 것 같다면, 그 요소가 위치할 곳으로 드라이버에게 스크롤(scrollBy나 scrollTo) 명령을 내리고, 그것을 찾았을 때 또 바로 다음에 원하는 요소가 또 있다면 현자 찾은 요소를 기준으로 스크롤을 내려버리면 그 다음 요소가 걸릴거다 (scrollIntoView). 혹은 현재 접속한 페이지가 페이스북이나 인스타그램처럼 스크롤을 잔뜩해야 더 많은 요소를 볼 수 있다면 무한 스크롤을 걸고, 무한 스크롤이 멈출 때 (더 이상 불러올 요소가 없을때... 사실 페북인스타트위터는 그러기 힘들다) 바로 driver.page_source를 받아와 뷰티풀 숲에 넘겨버리는 코드를 쓸 수도 있다.
또 뿐만 아니라, 탭네비-모달 형식으로 만들어져있는 사이트의 경우, 해당 탭을 클릭하고, 요소를 가져오고, 또 다음 탭을 클릭하고 다음 요소를 가져오고..를 반복하기는 좀 귀찮고 에러가 날 확률도 크니까. 개발자 도구에서 이 사이트의 동작 방식을 들여다보고 (탭 네비를 눌러보면서) 파악이 됐을 때 직접적으로 클래스 혹은 속성을 조작하는 방법이 있다.

01
요렇게 탭들을 딸깍 딸깍 누르면서 어떤게 켜지고 꺼지는 지 살펴본다.

그런 다음 해당 정보가 숨겨져 있는 (활성화 되지 않은 상태의) 모달 태그에 마우스 우클릭을 하고 Copy > JS Path를 복사한다. 그럼 보통 "document.querySelector("div.tab-content");" 이렇게 자바스크립트 코드와 CSS 선택자가 합쳐진 코드가 깔끔하게 (보통 이것보다 훨씬 길다. 내가 조작하고자 하는 값으로 간단하게 수정하면 된다. ) 나오는데 음 어떤 식이냐면, document.querySelector("#content > div.module.product-detail-tabs.animated.fadeInUp.in-view > div.tabs-content > div") 막 이렇게 길게 나올 수도 있는 거지. 그걸 내가 조작하고자 하는 태그만 필요하니 끝 부분만 잡아서 "div.tab-content"이렇게 똑 따내면 된다. 그리고 나서,


// 처음 JS path 를 복사하기 했을 때 보통 나오는 결과 
document.querySelector("#content > div.module.product-detail-tabs.animated.fadeInUp.in-view > div.tabs-content > div"); 


// 내가 필요로 하는 선택자만 남겼을 때 
document.querySelector("div.tab-content"); 


// 근데 위에처럼 하면 하나의 태그만 선택된다. 모든 모달을 동시에 보여지게 하고 싶으면 All을 삽입한다. 
document.querySelectorAll("div.tab-content"); 


// 콘솔에 입력해보면 NodeList로 여러 태그가 잡힐 것이다. 이런 식으로... 
NodeList(2) [div#tab-nutrition.tab-content, div#tab-feeding.tab-content.active] 


// 친절한 크롬에게 여기다가 내가 뭘 할 수 있을지 물어본다. 엔터를 입력한다. 
NodeList(2) [div#tab-nutrition.tab-content, div#tab-feeding.tab-content.active] 
  0: div#tab-nutrition.tab-content 
  1: div#tab-feeding.tab-content.active 
    length: 2 
    [[Prototype]]: NodeList 
    entries: ƒ entries() 
    forEach: ƒ forEach() 
    item: ƒ item() 
    keys: ƒ keys() 
    length: (...) 
    values: ƒ values() 
    constructor: ƒ NodeList() 
    Symbol(Symbol.iterator): ƒ values() 
    Symbol(Symbol.toStringTag): "NodeList" 
    get length: ƒ length() 

// 아니 사용할 수 있는 메소드까지 깰끔하게 알려주시다니..!!

여기서 forEach는 자바스크립트를 따로 배우지 않은 분들에게는 낯설 수 있는데, 그냥 단어 그대로 열거 가능한 각 요소들에게 똑같은 액션/메소드를 적용한다. 보통 내가 사용하는 것은 이런 식이다. 웹 개발을 해보신 분이라면 더 잘 이해하겠지만, 보통 특정 요소가 활성화되어 있을 때와 그렇지 않을 때를 구분하는 클래스명은 보통 active, hidden, disabled, 같이 직관적인 이름이고, 그렇지 않더라도 개발자 도구에서 확인 가능한 부분이라 걱정할 것 없다. 지금 다 이해할 수는 없다. 일단 갖다 쓰면서 익혀야함..


// 모든 선택된 태그에 "active"라는 클래스를 붙여줘!! (모달이 활성화됨) 
document.querySelectorAll("div.tab-content").forEach(e=>e.classList.add('active')); 


// 모든 선택된 태그의 style 속성을 "display: block;"으로 바꿔줘!! 
// (활성화된 태그는 인라인 스타일로 block을, 그렇지 않은 태그에는 none을 적용하는 모달의 경우 
document.querySelectorAll("div.tab-content").forEach(e=>e.style="display: block;"); 


// 내가 바꾸고자 하는 태그는 한가지 속성만 바꿔서 바로 보여지지가 않네?? 
// aria-expanded, hidden 같은 속성이 다중으로 적용되어 있는 경우 
document.querySelectorAll("div.tab-content").forEach(e=>{
    e.setAttribute('aria-expanded', true); 
    e.classList.add('active'); 
    e.classList.remove('hidden'); 
});

이런 식으로 코드를 작성할 수 있다. 이런 식으로 특정 모달을 띄우거나 모든 탭 네비게이션을 자유롭게 이동하고 싶을 때, display:none 등으로 숨겨져있는 자료를 모두 꺼내 보이게 하고 싶을 때 이 코드들을 driver.execute_script() 메소드 안에 넘겨주면 된다. (세번째 같이 여러줄의 코드는 """ """ 안에 넣어줘야 하겠지?) 그럼 보통 어지간한 스크래핑은 막힘 없이 할 수 있다. 뭔가 몇달 동안 애 먹은 코드를 한번에 다 풀고 나니까 기분이 허한데... 나도 다른 분들 포스팅 보면서 뜯어먹고 크고 있는 거니까 쨌든 공유를 해본다!!

반응형

댓글