크롤링이란

사실 들어가기 전에 크롤링이라는 것에 대해서 좀 언급을 하고 지나갈까 합니다.

현대인은 (극히 일부 세대를 제외하고) 이제 웹에 접속해서 정보를 찾는 것이 자연스럽게 되었습니다. 하지만 그 중에는 반복적으로 정보를 수집하는 과정에서 사람이 일일이 수집하는 것보다 컴퓨터로 하여금 자동적·주기적으로 수집하게 하는 것이 효율적인 경우가 존재합니다. 여기서 크롤러가 등장합니다.

즉 크롤러라 함은 1.주기적으로 데이터를 모아서 2. 처리하는 것 이라고 상정을 할 수 있습니다.

1.을 수행함에 있어서는 시스템의 기본적인 기능을 활용할 수 있습니다. 리눅스의 crontab 이라든지 macOS에서는 deprecate된 cron 대신 launchd 등을 쓸 수 있죠. 참조: Why is cron being deprecated? - Ask Different

2. 를 수행하기 위해서는 약간의 코딩 지식과 인터넷 연결이 필요합니다. 영어를 하는데 부담감이 없으면 더 좋죠.

왜 하필 네이버 사전인가

여기 문제의 트윗이 있습니다.



그런 겁니다.

Selenium을 이용한 동적 페이지의 크롤링

사실 보는 입장에서는 그 놈이 그 놈 같지만 크롤링하는 입장에서 동적 페이지는 정적 페이지와 접근 방식부터 달라져야 할 만큼 짜증나는 요소입니다. 사실 이 부분이 이 포스트를 작성하게 된 이유이기도 하고 말이죠.

우리가 보는 적지 않은 페이지는 정적 페이지입니다. HTML로 뼈대를 만들고 CSS로 살을 입히죠. Javascript는 매우 제한적으로 개입합니다. 덕분에 우리가 직접 브라우저를 띄워서 보거나 컴퓨터로 하여금 소스를 받아오게 하거나 결과물에는 차이가 없습니다.

하지만 동적 페이지의 경우는 다릅니다. HTML로 뼈대를 세우는 건 마찬가지지만 그 내용물을 Javascript가 채웁니다. 덕분에 컴퓨터를 시켜서 소스를 받아오게 하면 뼈대만 가져오고 내용물은 고스란히 빠지게 됩니다. 가져와보니 살은 없고 뼈만 있더라 가 됩니다.

그렇다고 크롤링을 아주 못하느냐면 그건 아니고 headless 브라우저라는 걸 쓰게 됩니다. 컴퓨터한테 실제로 브라우저를 열고1 거기에 보이는 소스를 그대로 가져오라고 하는 겁니다. 모든 스크립트가 로드된 상태로 소스를 받아오기 때문에 데이터가 고스란히 남은 결과물을 가져오게 됩니다. 참조: Selenium으로 무적 크롤러 만들기

Selenium은 본래 웹페이지/웹앱을 테스트하기 위해 시작된 프레임워크였지만 이런저런 webdriver를 지원하기 때문에 동적 페이지를 크롤링하는 데 안성맞춤인 도구입니다.

webdriver를 쓰는 데 있어서 한 가지 귀찮았던 부분은 어쨌든 스크립트가 모두 로드가 되어야 했기 때문에 일정 시간을 주어야 한다는 것이었습니다. webdriver 자체에도 스크립트가 로드될 때까지 기다리도록 설계가 되어 있다고는 하지만 역시 알 수 없는 이유로 로드가 전부 안 된 상태로 가져와서 강제로 쉬도록 해주어야 했습니다. implicitly_wait()2time 모듈에 있는 sleep()3 함수였습니다.

BeautifulSoup를 이용한 데이터 처리

사실 이건 딱히 이야기 할 것도 없고 BeautifulSoup Documentation에 잘 정리가 되어 있습니다. HTML 문서를 파싱해서 데이터를 뽑아내기 위한 라이브러리죠.

한 가지 고생했던 부분은 BeautifulSoup에서는 nth-child 를 쓸 수 없고 nth-of-type 만 사용이 가능한데 또 nth-of-type 은 클래스 태그 # 와는 사용할 수 없다는 것이었습니다. 결국 위에서 크게 잘라낸 덩어리 속에서 다시 찾는 식으로 해결을 하긴 했는데 그리 효율적인 해결책 같지는 않습니다만 그래도 일단 돌아가는 게 중요하니까(?)

긁어온 데이터 저장하기

데이터를 긁어왔으면 이걸 저장하고 다음에 긁어왔을 때 기존의 데이터와 비교해서 추가된 부분을 뽑아내는 작업도 필요했습니다. 기본 내장된 csv 파이선 모듈을 사용해서 csv로 저장하고 뽑아올 수 있었습니다. 참조: 예제로 배우는 파이썬 프로그래밍 - CSV 파일 사용하기

참고로 open() 에서 읽기 모드 'r', 쓰기 모드 'w' 외에 추가 모드 'a' 로 열면 기존 데이터는 남아있고 그 뒤에 데이터를 추가해서 적어넣을 수 있었습니다.

초기값을 설정하는 데 있어서 한 가지 고려해야 했던 게 비교할 데이터가 없는 처음에는 어떻게 해야 할까 생각을 해봤는데 csv 파일을 읽어오는 방식이 (무식하게도) 한 줄 한 줄 가져오는 방식이더군요. 그 방식을 응용해서 csv 파일의 라인 갯수를 세는 방법이 있었습니다. 참조: Count how many lines are in a CSV Python? - Stack Overflow

다만 이런 식으로 라인마다 불러와서 리스트에 추가하고 처리하는 방법은 시간이 지나면서 점점 속도가 느려진다는 단점이 있었습니다. 이 부분이 우려됐는데 우리 후임 말은 형 그건 어쩔 수 없어요 라고 하더군요. 제가 죽을 때까지는 어느 정도 쓸 수 있을 거라고는 생각을 합니다만 이 부분도 고민을 좀 할 필요가 있을 것 같긴 합니다.

여담: 데이터 내보내기

이전에도 언급했듯이 이 프로젝트의 가장 궁극적인 목적은 사전을 긁어다가 트윗으로 내보내는 것이었습니다. 일전에 블로그 포스트 공유 봇을 만들 때에도 썼던 twitter 라이브러리를 썼습니다만, 기왕이면 처음 트윗에 스레드로 줄줄이 엮어보고 싶다는 생각이 들었습니다. 딱히 문서화되어 있지는 않았지만 트위터 공식 API를 따른다는 문구를 보고 트위터 공식 API 문서 중 일부를 확인해보니 in_reply_to_status_id 라는 파라미터를 지원하고 있었습니다. statuses.update에 살짝 끼워넣으니 잘 동작합니다.

끝으로 내보낼 데이터를 선택하는 것은 기본 내장 라이브러리인 random 을 사용했습니다. random.randrage()len() 값을 주니 잘 동작합니다.

여담2

뭐 이건 아마도 나중에 저한테 도움이 될 것 같은 이야기지만, 본래는 os.getcwd() 함수를 이용해서 경로를 자동으로 잡으려 했습니다. 그리고 실제로 사용자 계정으로 구동했을 때는 아주 잘 작동해줬구요. 하지만 시스템 데몬인 launchd 로 동작을 시켜보니 경로를 디스크 최상단 경로인 / 로 잡더군요. 얼른 마무리하고 싶은 마음에 강제로 경로를 지정해줬지만 launchd 로 커스텀 데몬을 제작할 때에는 환경 설정도 해주어야 할 것 같습니다. 슬슬 그 부분을 건드릴 시간이 다가오는 것 같군요. 아마 저 부분에 대한 해법을 발견하면 이 프로젝트도 블로그 포스트 트윗 봇도 업데이트가 불가피하겠지요.


사실 적어놓고 보니 별 것 아니었구나 싶지만 저 한 대목 한 대목 해결하는데 최소 12시간에서 최장 48시간까지도 걸려서 프로젝트를 마무리 지었습니다. 단순한 오타가 초래한 문제도 있었고 로직 상의 문제를 발견을 못해서 돌아가다가 해결을 한 것도 있었죠4. 그래도 덕분에 많은 공부가 되었고 다음 프로젝트는 좀 더 쉽고 간명하게 마무리지을 수 있으면 하는 바람입니다.

코드는 Github 에 올려두었습니다.


참조한 페이지

  1. 물론 창은 안 보이게 해야겠지요. 

  2. 지금 보니 explicitly_wait() 도 있었군요. 

  3. http://bit.ly/2I1kEcU 

  4. 사실 저 48시간짜리가 알고보니 단순 오타 문제였다고 합니다. execute 라고 칠 것을 excute라고 쳤던 거죠.