-
[개발 일지] 특정 corpus를 넣었을 때 그 날 경기를 이겼는지 졌는지 판별해주는 분류기 만들기 (1) : 데이터 수집Machine Learning/NLP 2021. 6. 24. 21:37
자연어 처리를 간단하게나마 독학(이라고 하기에도 민망한 수준)하고, 실습을 해 보기로 하였다.
나는 야구를 좋아하므로 이번에도 야구 관련 프로젝트를 해 보기로 했다.
프로젝트의 목표는 이렇다.
다음과 같은 분류기를 만든다.
(예시로 주어진 코퍼스는 그저 예시일 뿐)
case 1:
input > "오늘 경기 실화....? 보는 내내 너무 스트레스 받음"
output > Lose
case 2:
input > "선수분들 너무 수고 많았어요! 특히 8회 만루홈런은 최고!"
output > Win이를 위해 아래의 절차들을 수행한다.
(1) 코퍼스 데이터, 승패 데이터 수집
(2) 적당한 전처리
(3) 분류기를 사용하여 분류
이번 글은 이 중 첫 번째 절차인 데이터 수집에 대해 개발 일지를 써 보려 한다.
개발 일지는 말 그대로 개발 일지, 나의 일기, 기록 뭐 이런 것이므로 학습으로서의 가치는 떨어질거라고 본다.
그러므로 혹시 정보가 필요하여 이 글을 찾아 온 사람은, 너무 이 글을 맹신하지는 말기를 바란다.
1. 코퍼스 데이터 수집 장소 결정
우선 이런 분류기를 만드려면, 코퍼스 데이터가 있어야 할 것이다. 데이터를 어디서 수집하느냐... 그 것이 첫 번째 문제였다. 왜냐하면, 내가 이런식의 스크랩핑(통상적으로는 크롤링이라고 부르는 듯하다. 사실 고정된 페이지를 단순히 긁어오는 거라 이런 전문적인 용어를 써도 되나 싶지만..)을 해본 적이 거의 없어, 우선적으로 윤리적인 문제를 걱정해서 그 부분에 대해 알아보았다. 그런데 각종 사이트의 robots.txt에 따르면 대부분의 사이트가 로봇이 긁어가는 것을 disallow한다고 되어 있었다. 그래서 그 부분에 대해 계속 알아봤는데 어딘가에는 크롤링(스크래핑) 자체는 불법이 아니고, 그렇게 해서 얻은 정보를 어떻게 사용하느냐에 따라 불법과 합법이 갈린다- 라고 했었다. (오히려 과도한 트래픽 유발이 더 문제점으로 지적되기도.) 나 같은 경우는 데이터들을 상업용이 아닌, 진짜 단순히 나의 실력 증진을 위한 학습용으로 쓸 것이기 때문에... 일단은 찜찜하긴 하지만, 프로젝트를 엎지 않고 데이터를 예정대로 수집하기로 했다. 혹시 제가 데이터 수집 과정에서 불법적인 일을 저지른 부분이 나와있다면, 댓글로 지적해주시면 감사하겠습니다.
일단 kbo 커뮤니티는 여러 군데가 있다. 당장 팀별 팬카페에서 부터 구단 인스타, 각종 커뮤니티들의 야구 게시판 등등.. 나는 커뮤니티를 하지는 않고, 가끔 정보글이 있나 몇 군데 인기글(?)들만 눈팅하는 정도이다. 이런 많은 커뮤니티들 중에서 데이터 수집에 적당한 커뮤니티들을 특정 기준을 정해 선별했다.
그 기준은 다음과 같다.
가. 폐쇄성이 없을 것
: 비회원은 열람 불가 등의 폐쇄성이 존재할 경우 데이터 수집 과정에서 윤리적인 문제가 발생할 수 있기 때문에.
나. 데이터의 양이 많을 것
: 이 프로젝트는 야구 경기가 있는 특정한 날의 코퍼스 데이터(X)와, 그 날 경기의 승패(Y)를 수집하여 분류기를 학습시키는 방법을 쓸 것이다. 따라서 그 특정한 날 하루에 생성되는 코퍼스가 방대해야 학습을 시키는 데 유리할 것이다. 즉, 쉽게 말하면 하루 글 리젠율이 높은 곳이어야 한다.
다. 구단(팀) 별로 게시판이 분리되어 있을 것
: 두 번째에서 언급했던 것과 같이, 데이터로 야구 경기가 있는 특정한 날의 코퍼스 데이터(X)와, 그 날 경기의 승패(Y)가 필요하다. 그런데 kbo의 팀은 한 개가 아니며 하루에 어떤 팀은 이기고 어떤 팀은 지기 때문에 구단 별로 글이 따로 분리되어있지 않으면 데이터를 수집하는 것이 굉장히 난감해진다. 게시판이 따로 분리되어 있을 경우, 특정 구단 게시판의 데이터를 수집해서 깔끔하게 그 날 경기 결과에 따른 label(win/lose)를 붙이기만 하면 된다.
의외로 이 세 가지를 충족시키는 곳이 별로 없었다. (어쩌면 당신들이 짐작하는 그 곳 하나밖에 없을지도 모른다.) 그렇게 kbo 커뮤니티 한 곳을 최종적으로 선정하였다. (어디인지는 따로 말하지 않겠지만 야구 팬이라면 어느 정도 짐작했을 것이다. 혹은 이 글을 읽어가면서 짐작할 것이다.)
2. 코퍼스 데이터 수집 방법(조건) 정의
제목이 모호한데... 기술적으로 데이터를 수집하는 방법이 아니라, 제목만 수집할 것인지 내용도 같이 수집할 것인지, 어느 시간대에 리젠되는 글들을 수집할 것인지 등을 정한다. 야구 경기의 시간대 등을 고려하여, 다음과 같이 정했다.
가. 평일엔 대략 22시, 주말(혹서기 기준 - 토 일 모두 17~18시에 경기 진행)엔 21시 전후로 경기가 끝나기 때문에, 그 때부터 글 리젠율이 급격히 떨어지는 다음 날 새벽 4시까지의 데이터를 수집한다. 야구가 없는 월요일에도 데이터를 수집하지 않는다. 즉, 데이터 수집 기간은 야구가 있는 화~금 당일 22:00 ~ 다음 날 04:00, 주말 당일 21:00 ~ 다음 날 21:00.
나. 글 내용과 댓글은 따로 수집하지 않기로 한다.
일단 귀찮고,글 제목만으로도 데이터로서 충분한 가치가 있기 때문에.3. 스크래핑을 위해 Requests, BeautifulSoup 사용
내가 스크래핑을 해본 적은 학교에서 Android Studio로 앱을 만들 때, 특정 사이트의 게시글들을 긁어오거나, 공공데이터 api를 이용해서 정보를 얻어왔을 때가 전부였다. (물론 그 때도 오직 학습을 목적으로만 앱을 개발하여 개인 소장했다.) 물론 그것과 큰 틀은 바뀌진 않겠지만, android studio와 python은 쓰는 라이브러리가 약간 다르더라. 이름만 많이 들어본 BeautifulSoup를 처음으로 제대로 사용해 보았다. 역시 간단, 쉬움!을 지향하는 대부분의 python 라이브러리 답게.. 이 라이브러리도 단순히 얕게 사용하면 굉장히 쉽다.^^ (물론 부가적인 기능까지 심오하게(?)사용하면 어려워지겠지..) 나는 빡대가리라 이런 라이브러리들이 정말 은혜롭다.
https://www.crummy.com/software/BeautifulSoup/bs4/doc/
Beautiful Soup Documentation — Beautiful Soup 4.9.0 documentation
Non-pretty printing If you just want a string, with no fancy formatting, you can call str() on a BeautifulSoup object (unicode() in Python 2), or on a Tag within it: str(soup) # ' I linked to example.com ' str(soup.a) # ' I linked to example.com ' The str(
www.crummy.com
공식 api에 들어가서 몇 줄 읽는 것만으로도 대략적인 사용법을 습득! 할 수 있다. 난 빡대가리에다가 영어도 못 하는데, 다행히 영어 울렁증이 도래하기 전에 개발진 분들이 친절하게 예시를 들어^^ 사용법을 설명해주신다. 덕분에 영어 울렁증으로 공식 api 사이트를 꺼버리기 전에 사용법을 습득할 수 있었다.
솔직히 이것만 읽어도 대략적인 사용법은 익힐 수 있는 듯 BS의 사용법을 간단하게나마 익힌 후, 사이트의 파싱을 위해 해당 사이트가 어떤 구조로 이루어져 있는지 html 태그를 분석한다. 다행히 페이지 소스와 UI를 일일이 대조하지 않아도 된다. 크롬의 개발자 도구(F12)를 이용하면 되기 때문.
형광펜 친 곳을 클릭한 후 원하는 컴포넌트를 클릭하면,
해당 컴포넌트와 대응하는 태그를 매치시켜준다. 와! 친절해라. 덤벙대는 나에게 딱 맞는 그림 가득하고 직관적인 기능!
이런 식으로 사이트의 UI를 분석한 후, 원하는 정보를 스크래핑 하기 위해서 BS 메소드로 어떤 식으로 코드를 짜야할지 구상한다.
BS는 단순히 html 문서에서 필요한 정보를 얻게 해주는 라이브러리이므로, 이를 사용하려면 html 문서를 가져와야 한다. 이 때 필요한 것이 requests 라이브러리. 이것도 역시나... 단순히 특정 url의 html만 추출하는 용도로 사용하는 건 매우 쉽다. get 메소드 하나로 다 끝난다.
https://docs.python-requests.org/en/master/user/quickstart/
Quickstart — Requests 2.25.1 documentation
Eager to get started? This page gives a good introduction in how to get started with Requests. Let’s get started with some simple examples. Passing Parameters In URLs You often want to send some sort of data in the URL’s query string. If you were const
docs.python-requests.org
get 메소드를 사용하기 위해서는 해당 사이트 url 분석이 필요하다. 데이터 수집의 대상이 되는 사이트에 들어가 url이 어떻게 구성되는지, 쿼리스트링에는 어떤 게 있는지 알아보았다.
[https://url]?id=[어쩌구]&page=3
다행히 페이지 번호를 쿼리스트링에 노출시킨 형태였다. 이 경우 다음 페이지를 탐색하기 위해 쿼리만 바꿔서 던져주면 되므로 탐색이 매우 쉬워진다. (물론 대부분의 커뮤니티가 이런 형태겠지만.. 가끔 가다 동적 div가 등장하는 곳도 있더라. 이 글에서도 나중에 등장한다.)
심지어 사이트를 계속 분석해 본 결과, 한 페이지 당 게시글 수도 쿼리로 조절할 수 있었다! 한 페이지 당 게시글 수를 최대로 지정해 놓으면, 동일한 시간대의 게시글들을 수집하기 위해 http request를 보내는 횟수도 줄어들 것이다. (앞서 언급했던 것처럼 스크래핑의 문제점 중 하나가 사이트에 과도한 트래픽을 줄 수 있다는 건데, 그것을 막기 위해 최소한의 request를 발생시키는 것만으로 스크래핑을 수행하는 코드를 짜려 한다.)
이렇게 분석을 끝내고, 해당 사이트에 맞게 url와 파라미터를 입력한 뒤 작동이 잘 되는지 테스트해보았다.
(※ 테스트 환경은 Jupyter Notebook)
Input
import requests url = 'https://xxxxxx.xx' params = { 'id' : [id], 'page' : [임의의 정수], 'list_num' : '100', } headers = { 'User-Agent' : [user_agent], } response = requests.get(url, params = params, headers=headers) response
Output
<Response [200]>
연결이 잘 되었음을 알 수 있다. 이 때 response가 담고 있는 내용(html 문서가 될 것)을 가져오려면 response.text(text형태)를 해도 되고, response.content(바이너리 형태)를 해도 된다. 이것을 BS에 넣어주면 된다.
방금 얻은 데이터를 파라미터로 BS 객체를 생성해준 뒤, 이를 이용하여 내가 원했던 데이터(글 제목)이 잘 수집되는지 테스트한다. (아래의 코드는 내가 이용한 사이트의 html을 기준으로 했기 때문에 다른 사이트와는 맞지 않는다. 그러므로 위에서 말했듯이 각 사이트의 태그를 분석한 후에 알맞는 코드를 만들어서 사용하자.)
Input
from bs4 import BeautifulSoup soup = BeautifulSoup(response.content) # 별다른 지정을 안 해줄 경우 html parsor로 파싱됨 # bs 객체를 이용해 필요한 부분만 가져옴 trs = soup.tbody.find_all('tr') title_list = [] for tr in trs: td = tr.find('td', class_='제목과 대응하는 클래스 이름') title = td.a.text title_list.append(title) title_list
Output
# 아래의 글 제목은 그저 예시임 ['글 제목 1', '글 제목 2', '글 제목 3', ... ]
잘 나오는 것을 확인한다.
4. 데이터를 효율적으로 담기 위한 클래스 정의
테스트 코드들이 전부 잘 작동하니, 이 것을 이용하여 본격적인 scraping 코드를 짜보려고 한다. 그 전에! 한 작업부터 먼저 진행하려 한다.
내가 최종적으로 원하는 데이터의 형태는, 코퍼스에 라벨이 붙여진 형태이다. 그러므로, 단순히 코퍼스 데이터만 저장할 것이 아니라 그에 상응하는 라벨(승패 데이터)도 저장해야 한다. 그러기 위해서는 단순히 리스트 형태의 자료구조로는 충분하지 않다는 것을 깨달았다. 클래스가 필요하다.
# 멤버 변수 : 날짜 별 승패 정보(딕셔너리), 날짜 별 코퍼스 데이터(딕셔너리) class KBOTeam: def __init__(self): self.win_lose = {} self.corpus = {} def addWinLose(self, date, value): # 승패 정보 딕셔너리에 데이터 추가 self.win_lose[date] = value def addCorpus(self, date, corpus): # 코퍼스 데이터 딕셔너리에 데이터 추가 self.corpus[date] = corpus def __str__(self): # 객체 자체를 출력했을 때, 출력 형식을 정함 return f'{self.win_lose}, {self.corpus}'
파이썬으로
교수님 코드 안 베끼고직접 객체지향 프로그래밍을 하는 것은 이번이 처음이다.(내가 제일 사랑하는 언어^_^인) C++로만 지겹도록 객체를 만들다 보니, 파이썬의 클래스 정의 방식이 처음엔 너무 어색했다. 아니!!!! 왜 멤버 변수를 생성자 안에다가 정의하는데,,, (생성자 밖에다 정의하면 C++의 static 멤버 변수가 된다고 함) 머리 비우고 private: public:부터 적던 게 그립ㄷㅏ... 아 물론 __str__은 정말 간편한 것 같다. 연산자 오버로딩 한다고 난리치지 않아도 되니까...ㅎㅎ
쓸데없는 소리가 길었다. 각설하고, 클래스도 만들었으니 본격적으로 스크래핑을 해볼까 한다.
5. 본격적인 Scraping
3.의 두 테스트 코드를 응용한다. 본질은 같고, 해당 코드를 이용하기 쉽게 함수로 묶어서 정의하거나, 스크래핑 조건들을 중간에 삽입하는 식으로 세세한 부분만 살짝 다듬어 줄 것이다.
먼저 활용성을 높이기 위해, 아까 전 html 불러오는 코드를 함수로 묶는다. 인자도 적절하게 설정해준다.
def getDocument(team_name, page_num): # html 소스를 가져오는 함수 url = 'https://xxxxxx.xx' # 나중에 공개적인 곳에 업로드할 일이 생길까봐 # user-agent 정보는 따로 빼내서 파일로 저장한 후, 해당 파일을 불러오는 방식으로 하여 # 하드코딩 방지와 보안 유지 with open('user_agent.txt', 'r') as file: user_agent = file.read() params = { 'id' : team_name, 'page' : str(page_num), 'list_num' : '100', } headers = { 'User-Agent' : user_agent, } response = requests.get(url, params = params, headers=headers) return response
그 다음 데이터를 수집하는 부분도 함수로 묶어줘야 하는데, 많은 작업을 하는 부분이다보니 코드를 짜기가 쉽지 않았다.
특히 날짜와 시간을 조건으로 사용했고 그 조건이 상당히 까다로웠던 점이 큰 몫을 했다.
2. 에서 언급한 조건을 코드에 적용시키기 알맞은 형태로 바꾸면 다음과 같다.
(조건 : 야구가 있는 화~금 당일 22:00 ~ 다음 날 04:00, 주말 당일 21:00 ~ 다음 날 04:00)
가. 특정 페이지 진입 ==>
해당 페이지의 글의 일부라도 조건에 부합하는가?
(이를 위해 각 페이지에서 가장 먼저 작성된 글, 즉 맨 밑에 있는 글부터 검사한다.)
-> Y(글 하나라도 조건에 부합) : 개별 글 하나하나를 탐색함
-> N(글 전부가 조건에 부합하지 않음) : 해당 페이지 패스. 바로 다음 페이지 탐색
나. 위에서 Y가 나온 경우 ==>
다. 해당 페이지의 글을 하나하나 탐색한다. ==>
해당 글이 작성된 시간이 조건에 부합하는가?
-> Y : 해당 글의 제목을 추출하여 저장한 뒤 다음 글로 이동
-> N : 해당 페이지 패스*. 바로 다음 페이지를 탐색. '가'부터 다시 시작 (시간의 역순으로 게시글이 정렬되는 게시판의 특성을 고려한 것이다. 게시글이 조건에 부합하다 갑자기 부합하지 않는 경계 부분은, 22시 이후에서 그 이전으로 넘어갈 때이다. 그러므로 한동안은 조건에 부합하는 게시글이 나타나지 않을 것임을 짐작할 수 있다.)이 때 위의 조건을 유심히 살펴 본 사람은 뭔가 이상한 것을 느낄 것이다.(* 해 놓은 부분) 저대로 가면 22시 이후에서 이전으로 넘어가는 부분을 검사하는 조건문이 실행될 경우 코드가 정상적으로 동작하겠지만, 4시 이후에서 이전으로 넘어가는 부분이 실행될 경우엔 해당 페이지를 검사하지 않아 그 이전 게시글 중 조건에 맞는 부분이 있더라도 검사를 하지 않고 넘어가는 불상사가 발생한다. 예를 들어 페이지의 첫 글이 04:01에 작성되었고 마지막 글이 02:00에 작성되었을 경우, 첫 글을 검사하고 해당 페이지를 그대로 패스해버려 조건에 맞는 수많은 글들이 제대로 수집이 안 된다.
이를 해결하는 방법은 여러가지겠지만, 나는 수집을 끝내는 글의 시간대를 모호하게 설정하는 방법을 썼다.
즉, 다. 아래의 조건문에서는 22시 이전인지 이후인지만 검사하고, 4시 이전인지 이후인지는 따로 검사하지 않는다. 그렇게 되면 페이지의 마지막 글만 조건에 맞을 경우, 중간 글이 새벽 4시 이후여도 정상적으로 글이 수집되는 사태가 벌어질 수 있으나, 고작 몇 분~시간 뒤의 게시글이 수집되는, 그리 심각한 문제는 아니기에 넘어가기로 했다. (그 다음 경기 시간 이후의 글까지 수집되는 건 문제가 되겠지만, 18시 30분 이후의 글까지 수집되는 일은 없을 것이다.)
이렇게 대략적으로 조건을 짠 후 이를 코드에 적용한다.
먼저 아까 전의 테스트 코드에서 조건이 들어가야 할 위치를 정한다.
soup = BeautifulSoup(response.content) # 별다른 지정을 안 해줄 경우 html parsor로 파싱됨 title_list = [] # 페이지 번호를 늘려가며 반복문이 돌아간다고 가정. while { # bs 객체를 이용해 필요한 부분만 가져옴 trs = soup.tbody.find_all('tr') # 조건 1 : 해당 페이지의 글의 일부라도 조건에 부합하는가? # N -> Continue for tr in trs: # 조건 2 : 해당 글이 작성된 시간이 조건에 부합하는가? # N -> break td = tr.find('td', class_='제목과 대응하는 클래스 이름') title = td.a.text title_list.append(title) }
이를 참고하여 전체 코드를 짠다.
(사실 선 코딩 후 일지 작성이라 이렇게 간단명료하게 글을 쓸 수 있는 거지, 실제로는 삽질을 꽤 많이 하는 바람에 이틀이 소요되었다... 잃어버린 이틀..! 처음에 페이지번호 기준으로 데이터를 반환하는 함수를 짰다가 날짜 기준으로 반환하는 것으로 기능을 바꾸면서 구조를 대부분 뜯어고쳐야 했다. (코드 구조도 더 복잡해짐) 날짜 또한 사이트 구조 때문에 발생한 에러가 생각보다 많아서 일일이 예외처리를 해 주어야 했고, 내가 논리적으로 잘못된 코드를 짜는 바람에 디버그 시간도 꽤나 소요되었다...^_ㅠ)
# team : 객체, team_name : id에 들어갈 내용(구단별 게시판마다 id가 다름), # start_date : 수집 시작 날짜, end_date : 수집 끝 날짜, start_page : 수집을 시작할 페이지 번호 def saveCorpusByDate(team, team_name, start_date, end_date, start_page=1): # date : yyyy-MM-dd format e_date_obj = datetime.date.fromisoformat(end_date) s_date_obj = datetime.date.fromisoformat(start_date) page = start_page now_date_obj = e_date_obj # 이 경우 다행히도 값이 복사가 되어 별도의 객체가 됨 title_list = [] # 말뭉치를 담을 배열 while now_date_obj >= s_date_obj: print(page) # debug response = getDocument(team_name, page) soup = BeautifulSoup(response.content) # 별다른 지정을 안 해줄 경우 html parsor로 파싱됨 # bs 객체를 이용해 필요한 부분만 가져옴 trs = soup.tbody.find_all('tr') if len(trs) < 1: # 왠진 모르겠는데 가끔 오류가 날 때가 있음... (페이지에 아무 게시글이 안 뜰 때) page += 1 continue if page < 2: # 예외 처리 trs = trs[2:] # 조건 1 : 우선 해당 페이지에 있는 글이 조건(기준 시간)에 부합하는지 판단 temp = trs[-1].find('td', class_='날짜에 대응하는 클래스명')['title'] temp_date = temp.split(' ')[0] # yyyy-MM-dd temp_date_obj = datetime.date.fromisoformat(temp_date) temp_time = temp.split(' ')[1][:2] # hh만 떼 오기 if (temp_date_obj - now_date_obj == datetime.timedelta(days=1) and int(temp_time) > 3 or temp_date_obj - now_date_obj > datetime.timedelta(days=1) or temp_date_obj.weekday() < 1): # 조언에 맞지 않는 시간대 혹은 월요일일 경우 page += 1 continue for tr in trs: if tr.find('td', class_='글 번호에 해당하는 클래스명').text == '공지': # 예외 처리 continue # 조건 2 : 개별 글의 시간이 조건에 맞지 않을 경우 수집X # 우선 게시글의 작성 시간부터 수집 _date = tr.find('td', class_='날짜에 대응하는 클래스명')['title'] # yyyy-MM-dd hh:mm:ss date = _date.split(' ')[0] # yyyy-MM-dd time = _date.split(' ')[1][:2] # hh만 떼 오기 # 조건 판별을 위한 변수 정의 date_obj = datetime.date.fromisoformat(date) yesterday_obj = date_obj - datetime.timedelta(days=1) # yesterday = yesterday_obj.strftime('%Y-%m-%d') isWeekend = True if date_obj.weekday() > 4 else False # 조건에 맞을 경우 파싱 진행 # 기준 시간 : (평일) 22시 ~ 4시 (주말) 21시 ~ 4시 if (not isWeekend and date_obj == now_date_obj and int(time) > 21 or isWeekend and date_obj == now_date_obj and int(time) > 20 or yesterday_obj == now_date_obj): # or yesterday_obj == now_date_obj and int(time) < 4): td = tr.find('td', class_='제목에 대응하는 클래스명') if td == None: # 예외 처리 td = tr.find('td', class_='제목에 대응하는 클래스명2') title = td.a.text title_list.append(title) # print(title) # debug else: # 조건을 넘어버린 경우, 더 이상 해당 페이지는 카운트하지 않는다. # 여태까지 카운트한 게시글 저장 now_date = now_date_obj.isoformat() team.addCorpus(now_date, title_list) # KBOTeam 클래스의 멤버 함수 # 말뭉치 배열 초기화, 그 다음 날짜로 이동 title_list = [] now_date_obj -= datetime.timedelta(days=1) if now_date_obj.weekday() < 1: # 월요일일 경우 전 날로 한 번 더 이동 now_date_obj -= datetime.timedelta(days=1) print(now_date_obj) # debug break page += 1 time.sleep(random.randrange(30)) # 사이트에 과도한 트래픽 걸리는 것 방지
위의 함수는 별다른 반환값을 가지지 않는다. 대신, 인자로 받은 kbo 객체의 멤버 함수를 이용하여, 멤버 변수 corpus에 대응하는 날짜를 key, 수집한 코퍼스 배열을 value로 하여 저장한다.
즉 저 함수를 실행하고 나면 객체의 멤버변수 corpus에는 다음와 같이 저장된다.
corpus = {'2021-06-24' : ['제목1', '제목2', ...], ...}
이로서 코퍼스 데이터를 수집하는 코드 작성이 끝났다.
6. Selenium을 이용하여 승패 데이터 수집
승패 데이터를 수집하기 위해, 코퍼스 데이터 수집에서 했던 것처럼 kbo의 경기 결과를 제공하는 사이트 몇 개를 후보로 두었다. 고민 끝에 KBO 공식 사이트가 적절할 것 같아 이로 결정했다. 해당 사이트의 robots.txt도 찾아보았고 경기 결과가 나와있는 /Schedule/는 Disallow가 되어 있지 않음을 확인했다.
내가 이 곳을 최종적으로 선정한 이유. 승리 팀과 패배 팀을 tag의 class명으로 구분을 해서 간단하게 승패 정보를 저장할 수 있다. 자, 이제 아까처럼 Requests와 BS를 이용해서 데이터를 수집하면 되겠지~ 하고 룰루랄라 코드를 짰었다.
그런데 웬걸. 승패 결과 표 컴포넌트에 해당하는 table 클래스가 보이지 않았다.
이게 무슨 일인가 싶어 부랴부랴 구글링을 했더니, 다음과 같은 정보를 얻을 수 있었다.
"BS는 단순히 웹 페이지의 소스를 가지고 요리하는 식이라, 동적 div의 경우 처음에 페이지 소스를 가져올 때 잡히지 않으므로, 정보를 얻는 것이 불가능하다. 다른 방법으로 html 소스를 얻어와야 한다."
라고 한다. (내가 웹 프로그래밍 과목을 안들었기도 하고 웹에 별로 관심이 없어서 자세히는 잘 모르겠다만.. 대충 뭐 이런 뉘앙스였다.)
즉, 경기 결과가 나와있는 표는 동적 컴포넌트이기 때문에, 이에 해당하는 html 소스를 가져오려면 Requests 대신 다른 방법을 써야 한다는 것. 이 때 필요한 것이 셀레니움(selenium)의 webdriver이다.
Chrome Web Driver는 아래의 링크에서 다운로드 가능하다. 다운로드 받은 후 적당한 곳에 저장하고, 저장 경로를 기억해두자.
https://chromedriver.chromium.org/downloads
ChromeDriver - WebDriver for Chrome - Downloads
Current Releases If you are using Chrome version 92, please download ChromeDriver 92.0.4515.43 If you are using Chrome version 91, please download ChromeDriver 91.0.4472.101 If you are using Chrome version 90, please download ChromeDriver 90.0.4430.24 If y
chromedriver.chromium.org
WebDriver의 간단한 튜토리얼은 동일한 웹사이트의 이 페이지에서 볼 수 있다.
조금 더 자세한 문서는 아래 링크 참고. (위 튜토리얼과 다르게 여기에서는 환경 변수를 지정한다.) 크롬 말고 다른 웹 브라우저에 대한 설명도 나와 있으니 다른 브라우저를 쓴다면 참고 바람.
https://www.selenium.dev/documentation/ko/webdriver/driver_requirements/
드라이버 요구사항 :: Selenium 문서
드라이버 요구사항 Selenium은 WebDriver를 이용하여 Chrom(ium), Firefox, Internet Explorer, Opera, Safari와 같은 시장의 모든 주요 브라우저들을 지원합니다. 모든 브라우저가 원격 제어에 대한 공식적인 지원
www.selenium.dev
저 튜토리얼에 나와있는 것처럼 웹 페이지를 열어서 조작(?)까지 해야 selenium을 제대로 사용한 것이겠지만, 우리는 html 소스만 있으면 되므로 driver 객체 생성 후 동적 div가 생성될 때 까지 기다려준 뒤 페이지 소스를 가져오기만 하면 끝이다. 간단.
그럼 코딩을 해 보자. BS를 쓰는 것까지는 똑같으나, 그 이전에 requests.get을 쓰는 대신 다음과 같은 코드를 써 준다.
Input
from selenium import webdriver driver_path = 'chrome driver 저장 경로' url2 = 'https://xxx.xx' driver = webdriver.Chrome(driver_path) # 환경 변수를 설정해 주었을 경우 인자를 주지 않아도 됨 driver.get(url2) time.sleep(5) # 사실 이거 빼도 잘 돌아갔다. 웹바웹인듯 content = driver.page_source content
Output
(대충 <table> 태그가 포함된 웹 페이지 소스)
(환경 변수를 지정해주었다면 driver_path가 들어가는 곳을 비워두어도 된다. 하지만 난 이쯤 되니 너무 지쳐 환경 변수 설정하기가 너무 귀찮은 나머지 그냥 이렇게 코딩했다.^^)
결과가 성공적으로 나오는 것을 확인. 이제 BS를 이용하여 나머지 부분의 코드도 짜준다.
코퍼스 수집할 때보다 상당히 알고리즘이 간단하므로 따로 논리 구조를 서술하지는 않겠다. 코드를 읽는다면 이해가 갈 것이다. 인자로 들어간 변수 kbo_teams에 대한 설명은 main을 작성할 때 따로 하려 한다.
# kbo_teams : KBO 클래스 타입의 객체를 담은 딕셔너리 def saveGameResult(kbo_teams): # 승패가 기록된 tbody 태그가 동적 div이므로 셀레니움을 이용 driver_path = 'chrome driver 경로' url2 = 'https://xxx.xx' driver = webdriver.Chrome(driver_path) driver.get(url2) # time.sleep(5) content = driver.page_source # html을 얻었으면 bs로 파싱~ soup2 = BeautifulSoup(content) soup2.tbody trs2 = soup2.tbody.find_all('tr') for tr in trs2: # 날짜 얻기 day = tr.find('td', class_='day') if day != None: date = '2021-' + day.text.replace('.', '-')[:-3] # 'yyyy-MM-dd' # 경기 정보 얻기 play = tr.find('td', class_='play') spans = play.find_all('span') # ex : KT 8 vs 1 LG rain_check = tr.find_all('td')[-1].text # 비고(우천취소 or -)란을 가져옴 if len(spans) < 5 and rain_check == '-': # 아직 경기가 진행되지 않은 날짜의 경우 break break # 경기 정보 파싱해서 기록 if rain_check != '우천취소' and spans[1]['class'][0] != 'same': # 무승부, 우천취소인 경우 승패 기록X t1_name = spans[0].text t1_result = True if spans[1]['class'][0] == 'win' else False t2_name = spans[4].text t2_result = True if spans[3]['class'][0] == 'win' else False kbo_teams[t1_name].addWinLose(date, t1_result) # KBOTeam 클래스의 멤버 함수 kbo_teams[t2_name].addWinLose(date, t2_result)
이 코드를 유심히 봤다면, 이 코드가 웹 페이지에 들어가면 바로 보이는 달(여기서는 2021년 6월)의 경기 결과만 가져온 다는 것을 알 수 있을 것이다. 맞다. 그 이전의 경기 결과를 가져오려면 selenium을 이용해 별도의 코드를 짜 주어야 한다. (이전 달로 넘어가는 버튼을 찾아 selenium의 submit() 함수를 실행시키면 다음 달로 넘어갈 테고, 똑같이 html 소스를 받아와 BS로 스크래핑하면 될 것 같기는 하다...) 그러나 굳이 그렇게까지 구현하지는 않았다. 왜냐하면 이 코드를 작성한 시점이 6월 보름경이었는데 2주간의 데이터만으로도 충분히 모델을 만들 수 있을 것이라 생각했고, 무엇보다 귀찮기 때문이었다...
어쨌든 해당 함수를 실행시켜주면 KBO_Team 객체의 멤버 함수 win_lose에 다음과 같이 저장될 것이다.
win_lose = {'2021-06-01' : False, '2021-06-02' : True, ...}
7. main 작성 : 데이터 수집
이제 필요한 함수들도 다 작성했겠다, main 함수를 짠 뒤 본격적으로 데이터를 수집해보려 한다. 데이터 수집 기간은 2021년 6월 1일부터 6월 13일까지.
데이터 수집은 해당 사이트들의 서버에 가급적 지장을 주지 않기 위해 트래픽이 적은 새벽 시간대에 수행할 계획이었고, 코퍼스 데이터의 경우 원래는 10개 구단을 한꺼번에 반복문으로 (물론 그 사이에 충분한 텀을 두고) 수집하려 했었다. 그러나 이조차도 과도한 트래픽을 유발할 것 같아, 최종적으로는 각 구단을 하나씩 하드코딩해가며, 구단 사이에 10분~1시간찍 텀을 두고 데이터를 수집했다.
데이터 수집에 앞서, 원활한 코딩을 위해 몇 가지 객체들부터 생성하려 한다.
반복문을 수행하기 위해 구단 명을 담은 배열을 생성했다.
또, KBOTeam 객체를 보다 잘 관리하기 위해 객체를 value로, 구단 명을 key로 가지는 kbo_teams 딕셔너리를 생성했다.
# main # 구단 이름 순서는 그냥 생각나는 대로 kbo_team_names = ['삼성', '키움', 'SSG', '두산', '한화', '롯데', 'KIA', 'KT', 'LG', 'NC'] kbo_teams = {} # 구단 객체를 담을 딕셔너리 for name in kbo_team_names: kbo_teams[name] = KBOTeam() ids = [각 구단별 ids 파라미터 값] # kbo_team_names의 순서와 동일
생성한 kbo_teams는 승패 정보를 수집하는 함수의 인자로 들어간다.
아까 정의한 승패 수집 함수를 호출해 10구단의 승패 정보를 저장한다.
# 10구단 2021-06-01 ~ 2021-06-13 까지의 경기 기록 저장 saveGameResult(kbo_teams)
승패 데이터 수집이 완료되었으면 코퍼스 데이터 수집을 시작한다.
주석에도 나와있듯이 실제로는 저 반복문을 쓰지 않았다. 앞서 말한 것처럼 과도한 트래픽의 우려도 있었고, 중간에 에러가 나면 처음부터 다시 수집해야 했기 때문에...
# 실제로는 반복문을 사용하지 않고 구단 하나씩 하나씩 인자로 넣어가며 데이터 수집함.. for id, name in zip(ids, kbo_team_names): print(name) saveCorpusByDate(kbo_teams[name], id, '2021-06-01', '2021-06-13') time.sleep(600) # 사이트에 과도한 트래픽 걸리는 것 방지
실제로는 이런 식으로 수집했다.
# (1) 삼성의 기록 수집 saveCorpusByDate(kbo_teams['삼성'], 'id', '2021-06-01', '2021-06-13', 25)
이렇게 10개 구단까지 수행하면 모든 구단의 코퍼스 데이터 수집이 끝난다.
8. main 작성 : 수집한 데이터 저장
그럼 이렇게 수집한 코퍼스 데이터와 승패 데이터는 어떻게 저장할까?
방법은 여러 가지 있을 것이다. text나 csv 파일로 저장해서 데이터를 사용할 때 적당히 파싱해서 쓰는 방법도 있을 것이고... 하지만 그런 방식은 저장하고 불러올 때 데이터를 어떤 식으로 조합하고 분해(파싱)해야 할 지 정해야 하기 때문에 귀찮아진다. 이런 나같은 사람들을 위해 은혜로운 라이브러리가 하나 있다^^ 바로 pickle!
pickle은 파이썬 객체 자체를 파일로 저장하게 해주는 라이브러리이다. 복잡하게 문서 형태로 바꾸어서 저장하고 불러와서 다시 객체로 바꾸는 수고로움을 덜 수 있는 것. pickle 공식 문서보다 이 글에 설명이 더 잘 되어있다.
https://korbillgates.tistory.com/173
[python] 파이썬 pickle 피클 활용법
안녕하세요 한주현입니다. 오늘은 python의 pickle 을 활용하는 방법에 대해 알아보겠습니다. 들어가며 - pickle 은 무엇? 언제 쓰나요? 파이썬 피클에 대해서 알아봅시다 ㅎㅎ 텍스트 상태의 데이터
korbillgates.tistory.com
워낙 사용법이 간단해서 파일 저장하듯 저장하고, 파일 불러오듯 불러오면 된다. 불러온 뒤에는 원래 객체처럼 사용하면 된다. (물론 객체가 사용자 정의 클래스 자료형인 경우, 사전에 그 클래스가 정의되어 있어야 한다.)
pickle은 보안 측면에서는 신뢰할 수 없으므로 보안이 중요한 데이터는 피클화하면 안되는 듯 하다.
이 pickle을 이용하여 KBOTeams 객체를 저장하고, 시험 삼아 하나를 불러와봤다.
원래 객체 저장도 반복문으로 수행하려 했으나 데이터 수집을 일일이 해버려서 그냥 객체 저장도 일일이 했다.
(구단 1 데이터 수집 -> 구단 1 객체 저장 -> 구단 2 데이터 수집 -> 구단 2 객체 저장 -> 반복..)
객체 저장
# 삼성 데이터 저장 with open('../datasets/samsung.pickle', 'wb') as fw: pickle.dump(kbo_teams['삼성'], fw)
객체 불러오기
Input
with open('../datasets/samsung.pickle', 'rb') as fr: ss = pickle.load(fr) ss.corpus['2021-06-01']
Output
['제목 1', '제목 2', ... ]
이렇게 첫 번째 파트, 데이터 수집이 끝났다. 왠지 배보다 배꼽이 더 큰 느낌인데... 기분 탓이겠지.
다음에는 이렇게 수집된 데이터를 분류기에 넣어 학습시키기 전에 전처리를 수행해볼까 한다.