파이썬을 이용한 대학교 학사일정 크롤링 (requests, BeautifulSoup, vobject)
업데이트:
깃허브 소스코드
- 본 프로젝트에 대한 소스코드는 여기를 클릭하면 확인 가능합니다. (깃허브 리포지토리)
들어가기에 앞서, 이건 사실 크롤링이 아니다.
이 프로젝트는 엄밀히 따지자면 크롤링이라기 보다는 단일 HttpRequest 수준에 불과한 정도다. CSS 선택자 하드코딩으로 인해 웹 페이지의 DOM 구조에 대한 종속성을 띄기 때문에 DOM에 변동이 있으면 프로그램은 작동하지 않게 된다. 그러나 서술의 편의상 크롤러/크롤링 용어를 사용하게 되었다.
정확한 크롤링은 단순히 말해 구글, 네이버 등의 웹 포털 사이트와 핀터레스트 등의 서비스에서 수천, 수만, 수억개의 페이지를 ‘긁어오는’것을 생각하면 된다. (이렇게 긁어온 글을 검색 유저에게 보여주는 서비스가 포털 사이트이니까) 이에 비하면 본 프로젝트의 크롤러는 단순 일회성 요청에 불과한것이다.
또한 robots.txt의 정책 위반 걱정도 없다. 용인예술과학대학교 (구 용인송담대학교)의 웹사이트에 존재하는 robots.txt를 보면 다음과 같다.
1
2
3
User-Agent: *
Allow: /
이는 모든 접근자(사람이나 크롤러, 프론티어 등의 ‘로봇’을 포함한 모두)에게 모든 페이지를 요청 할 수 있다는 의미로, 무단 크롤링에 대한 걱정이 없음을 알 수 있어 robots.txt 권고안을 위배하는 행위도 아닌것이다. 따라서 본 프로젝트가 정보의 무단 수집 행위가 아님을 확인한 후 작업하였다.
서론 : 계획은 좋았다
고등학생과 대학생의 차이는 학생이 성인인가가 가장 크다. 한마디로, 대학생은 누가 챙겨주지 않는다. 당연한 일이지만 알림장도 없고, 학사일정을 알려줄 담임선생님도 계시지 않는다.
대면수업을 받는 동안에는 학우들과 교수님을 직접 만나거나 뵈며 소식을 주고받을 수 있으나 전공심화 과정은 대부분 비대면이며, 간혹 있는 대면수업도 야간에 진행하여 이제는 그것은 내게 먼 일이 되었다.
그리하여 학사일정을 내가 직접 알아봐야 하는 상황이 되었으나 대학교 학사일정 확인은 매우 불편하게 되어있어 일일히 대학교 홈페이지에서 확인해야 했다.
때문에 나는 이를 캘린더 프로그램에 담아 따로 관리하며 클라우드를 통해 기기 간 자동 동기화되는 일정으로서 편하게 확인하고 알림을 받을 수 있도록 만들고 싶었다.
캘린더 프로그램은 내가 직접 만들기보다 이미 만들어진 좋은 프로그램이나 서비스가 많다. 당장 구글 캘린더, 애플 캘린더, 네이버 캘린더 등이 떠올랐다. 그 중에서도 국내 서비스인 네이버 캘린더를 사용해보고 싶었다.
그래서 처음 계획은 학사일정에서 습득한 학사일정 데이터를 정제하여 네이버 캘린더에 등록함으로써 일정을 클라우드 플랫폼에서 관리하는 것이었다.
학사일정 데이터 크롤링은 어렵지 않았다. 지난 수 년간 변하지 않은 DOM을 가진 단일 페이지이기에 하드코딩으로 데이터를 가져오고 정제해도 무방하였기 때문이다. 안일한 생각이지만, 지금의 경우에는 문제가 없을것이라고 판단했다.
그러나 네이버 캘린더 API 연동과정에서 많은 트러블슈팅이 있었고 결국 개발방향을 바꾸어 로컬 캘린더 파일인 .ics 확장자 파일로 저장하는 방식을 선택해야 했다.
이 과정에서 우리가 일상적으로 사용하는 캘린더 서비스에도 CalDAV 웹 규격과 iCalendar 파일 형식의 존재에 대해 알게 되었다. 주로 이메일 등으로 일정을 상호 공유하기 위해 제작되었다고 한다.
iCalendar파일의 대표 확장자는 .ics
이며, 캘린더 플랫폼간 수동 연동 작업을 해 본 경험이 있다면 CalDAV라는 단어도 본 기억이 있을 수 있겠다. 대부분의 클라우드 캘린더 서비스도 내부적으로는 ics파일을 사용하는것으로 추정된다.
본론 : 학사일정 가져오기
앞서 언급하였듯이 대학교 홈페이지에서 학사일정을 가져오는 작업은 크게 어렵지 않았다. 깃허브 리포지토리의 getUnivCal.py에서 확인 할 수 있다.
1
2
3
import requests
from bs4 import BeautifulSoup as bs
from datetime import datetime as dt
학사일정을 가져오고 정제하기 위해 사용한 모듈은 위와 같다.
1
2
3
4
5
6
7
8
9
10
year = dt.now().year
month = 1
day = 1
desc = ""
# 대학 학사일정 사이트
url = 'https://www.ysc.ac.kr/kor/CMS/ScheduleMgr/MonthList.do?mCode=MN088&calendar_year='+str(year)+'&calendar_month='
# 크롤링 한 정보를 저장할 배열
scd = []
가장먼저 위와같이 필요한 변수를 선언 및 초기화하였다. 연, 월, 일 변수를 각각 현재 해, 1월, 1일(year, month, day) 값으로 초기화 하고 일정 내용(desc), 학사일정 페이지, 일정을 담을 배열을 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
while month < 13 :
source = requests.get(url+str(month))
soup = bs(source.content, 'lxml')
for t in soup.find_all('td'):
if(t.find('li') is not None): # 일정이 있는 날의 경우 ul태그는 있으나 li태그는 없음
day = t.find('div', class_='day-tit').text
for li in t.find_all('li'):
desc = li.text
scd.append([str(year), str(month), day, desc])
month+=1
다음으로 while 반복문을 통해 올 해 1월 1일부터 12월 31일 중 일정이 있는 날짜만 배열에 추가하도록 작성하였다. month는 초기 변수값을 1씩 늘려 12까지 사용하고 day와 desc는 일정 데이터를 참조하여 재정의한다.
학사일정 페이지의 url에는 파라미터를 통해 원하는 연도와 달을 가진 달력을 요청할 수 있다. 따라서 requests 모듈을 통해 원하는 달력을 받아 온다.
bs4를 사용하여 데이터에 접근 할 때 이중 for문이 사용된다. 이 때 고민한것이 코드의 가독성이었고, 가장 첫 반복문에 while을 사용하게 된 것이다.
6번 줄 : 일정이 없는 데이터는 담지 않기 위해 가장 먼저 if 조건문으로 li
태그가 없는 날짜를 걸러내며 조금의 최적화를 하였다.
9번 줄 : 하나의 날짜에 여러 일정이 있을 수 있기 때문에 find_all
로 모든 정보를 각각 가져왔다.
필요한 모든 데이터가 준비되면 [연, 월, 일, 일정]
형식의 배열로 만들어 scd배열에 추가한다. 즉, scd배열은 2차원배열이 된다.
이렇게하면 대학교 학사일정 페이지의 당 해 모든 학사일정을 가져올 수 있다. 여기까지는 전혀 어려움이 없었다.
본론 : 네이버 캘린더에 학사일정 등록해보기
학사일정 데이터 가공을 마쳤으니 남은 일은 이를 캘린더 서비스에 집어넣는 일이었다. 네이버 캘린더에 접근하기 위해 네이버 디벨로퍼스에서 제공하는 네이버 오픈 API를 사용하기로 하였다.
네이버에서는 위와 같이 다양한 오픈 API를 제공하여 네이버 서비스와 연동할 수 있는 다양한 파생 서비스를 개발 할 수 있도록 개발자를 지원하고 있으며 개발자 포럼을 운영하여 서비스 이용자 간 정보를 공유 할 수 있도록 돕고있다.
네이버 오픈 API를 사용하려면 네이버에 로그인 한 후, 애플리케이션 등록 신청을 해야했다. 애플리케이션 이름, 사용 API 종류, 로그인 오픈API 서비스 환경을 필수로 입력해야 했는데, API 종류에서 캘린더를 선택하면 자동으로 네이버 로그인 기능또한 추가된다.
아래에 “반드시 검수에 통과되어야 네이버 로그인 사용이 가능하다”는 내용이 있어 ‘이걸 기다려야 하고 오래걸리겠구나’ 하고 생각했으나 다행히 개발중인 상태에서의 로컬 요청은 가능하여 기다림 없이 코드 작성이 가능하였다.
외부에 공개 서비스 할 프로젝트가 아니기 때문에 도메인 및 서버 없이 서비스 URL로 localhost를 기재하였다. 이 URL을 통해야만 네이버 로그인이 동작하였다.
애플리케이션 등록이 끝난 후에는 네이버에서 제공하는 예제 코드를 복사하여 각 API 명세를 참조하여 프로필에 맞게 코드를 수정 후 테스트를 하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# 네이버 API 예제 - 캘린더 일정 추가하기
import os
import sys
import urllib.request
token = "YOUR_ACCESS_TOKEN"
header = "Bearer " + token # Bearer 다음에 공백 추가
url = "https://openapi.naver.com/calendar/createSchedule.json"
calSum = urllib.parse.quote("[제목] Py 캘린더API로 추가한 일정")
calDes = urllib.parse.quote("[상세] 회의 합니다")
calLoc = urllib.parse.quote("[장소] 그린팩토리")
uid = token[1:15] # UUID 생성 (임시로 일단 토큰값을 잘라서 사용)
scheduleIcalString = "BEGIN:VCALENDAR\n"
scheduleIcalString += "VERSION:2.0\n"
scheduleIcalString += "PRODID:Naver Calendar\n"
scheduleIcalString += "CALSCALE:GREGORIAN\n"
scheduleIcalString += "BEGIN:VTIMEZONE\n"
scheduleIcalString += "TZID:Asia/Seoul\n"
scheduleIcalString += "BEGIN:STANDARD\n"
scheduleIcalString += "DTSTART:19700101T000000\n"
scheduleIcalString += "TZNAME:GMT%2B09:00\n"
scheduleIcalString += "TZOFFSETFROM:%2B0900\n"
scheduleIcalString += "TZOFFSETTO:%2B0900\n"
scheduleIcalString += "END:STANDARD\n"
scheduleIcalString += "END:VTIMEZONE\n"
scheduleIcalString += "BEGIN:VEVENT\n"
scheduleIcalString += "SEQUENCE:0\n"
scheduleIcalString += "CLASS:PUBLIC\n"
scheduleIcalString += "TRANSP:OPAQUE\n"
scheduleIcalString += "UID:" + uid + "\n" # 일정 고유 아이디
scheduleIcalString += "DTSTART;TZID=Asia/Seoul:20161116T190000\n" # 시작 일시
scheduleIcalString += "DTEND;TZID=Asia/Seoul:20161116T193000\n" # 종료 일시
scheduleIcalString += "SUMMARY:" + calSum + " \n" # 일정 제목
scheduleIcalString += "DESCRIPTION:" + calDes + " \n" # 일정 상세 내용
scheduleIcalString += "LOCATION:" + calLoc + " \n" # 장소
#scheduleIcalString += "RRULE:FREQ=YEARLY;BYDAY=FR;INTERVAL=1;UNTIL=20201231\n" + # 일정 반복시 설정
scheduleIcalString += "ORGANIZER;CN=관리자:mailto:admin@sample.com\n" # 일정 만든 사람
scheduleIcalString += "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN=admin:mailto:user1@sample.com\n" # 참석자
scheduleIcalString += "CREATED:20161116T160000Z\n" # 일정 생성시각
scheduleIcalString += "LAST-MODIFIED:20161116T160000Z\n" # 일정 수정시각
scheduleIcalString += "DTSTAMP:20161116T160000Z\n" # 일정 타임스탬프
scheduleIcalString += "END:VEVENT\n"
scheduleIcalString += "END:VCALENDAR"
print(scheduleIcalString)
data = "calendarId=defaultCalendarId&scheduleIcalString=" + scheduleIcalString
request = urllib.request.Request(url, data=data.encode("utf-8"))
request.add_header("Authorization", header)
response = urllib.request.urlopen(request)
rescode = response.getcode()
if(rescode==200):
response_body = response.read()
print(response_body.decode('utf-8'))
else:
print("Error Code:" + rescode)
본론 : 네이버 캘린더 API - 트러블 슈팅
캘린더 API의 대략적인 동작 흐름은 네이버 로그인 및 연결 - 로그인 한 유저의 토큰 리턴(서버 필요) - 유저 토큰으로 캘린더 API 접근 - 일정 정보 추가/수정 요청(토큰 포함)
과 같다.
여차저차 하여 파이썬으로 로컬 서버를 열고 네이버 로그인을 하여 토큰을 획득한 후 캘린더에 테스트용 일정을 생성하여 요청을 넣어보았다.
캘린더 일정 생성 요청에 대한 응답 결과값 으로{"result":"success","returnValue":{"processType":"create","calendarId":"00000000"}}
을 확인하여 정상 동작을 확인하였다.
그런데 실제 캘린더에는 적용이 되지 않았다. 몇 번의 확인에도 코드에는 아무런 문제도 없었다. 이윽고 나는 포럼에 관련 글을 검색해보았고, 동일한 문제를 겪는 글을 찾을 수 있었다.
예제코드의 내용 중 특수문자 기호+
가 %2B
로 대치되어 있는것을 다시 +
로 바꾸면 해결된다는 댓글이 있었기에 시도해보았으나 개선은 없었다. 오히려 오류코드 500을 출력하였다.
바로 다음 댓글에 iCalendar를 검사해주는 사이트가 있다고 하여 검사를 돌려보았고 구조적인 문제가 여럿 출력되었다. 몇 번의 검증으로 예제코드의 iCalendar는 일종의 네이버를 위한 독자규격이라 판단하여 도움이 되지 않을것이라는 결론을 내렸다.
조금 찾아보니 이에 대한 포럼 글을 확인 할 수 있었다. 결론적으로 %2B
를 사용하는것이 맞다는 내용이었다.
이후로도 여러 글을 참고하며 수정을 거듭하였으나 문제를 해결 할 수는 없었고, 특정 캘린더 그룹에 따로 일정을 추가 하는 기능은 없이 기본 캘린더에만 추가 할 수 있다는 한계가 있어 네이버 캘린더 연동을 포기하게 되었다.
본론 : 프로젝트 방향 수정 - ics 확장자 로컬 파일 저장으로
그렇게 결국은 로컬 파일 형식으로 학사 일정을 추출하도록 프로젝트 방향을 변경하게 되었으니, 이제 캘린더 정보를 파일로서 저장하는 방법을 알아야 했다.
검색을 해보니 파이썬에서는 vobject라는 패키지로 iCalendar 파일을 파싱하거나 생성할 수 있는것을 알 수 있었다.
vobject를 이용하는 방법은 Python을 이용한 iCalendar(ics파일) 만들기 - 개발자 Jinn의 블로그에서 잘 소개가 되어있어 해당 글의 예제를 보며 프로젝트에 적용 할 수 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from datetime import datetime
import vobject
# 연 월 일 장소정보 파라미터
def saveCal(year=int,month=int,day=int,desc=str):
scd = vobject.iCalendar()
# 현재 시간
curTime = datetime.now()
# 일정 시작 (시간은 09시 20분 0초 고정)
dayOn = datetime(year,month,day,9,20,0)
# 일정 종료 (시간은 18시 30분 0초 고정)
dayOff = datetime(year,month,day,18,30,0)
vevent = scd.add('vevent')
vevent.add('summary').value = desc
vevent.add('description').value = desc
vevent.add('dtstart').value = dayOn
vevent.add('dtend').value = dayOff
vevent.add('dtstamp').value = curTime
vevent.add('location').value = "용인예술과학대학교" # 장소는 대학교 고정
코드를 위와같이 수정하여 설계한 의도대로 동작하는것을 확인하였다. 이제 남은일은 vobject로 생성한 캘린더 객체를 파일로 저장하고 코드를 정리하는것이다.
1
2
3
4
5
6
7
8
9
10
11
from os import path
from os import getcwd
from os import makedirs
# 코드 생략
# ics폴더 아래에, 2024_09_03_동계.ics 등의 형식으로 iCalendar 파일 저장
file = pathManager(dayOn.strftime("%Y_%m_%d_")+desc[:2]+".ics")
with open(file, 'wb') as f:
f.write(scd.serialize().encode('utf-8'))
1
2
3
4
5
6
7
8
def pathManager(newFileName):
icsPath = path.join(getcwd(), "ics")
if not path.exists(icsPath):
makedirs(icsPath)
print(path.join(icsPath, newFileName))
return path.join(icsPath, newFileName)
따라서 위와같이 saveCal()에는 파일을 저장하는 코드를 추가하고, pathManager 함수를 아래에 추가하였다.
코드 라인 수가 많지는 않아 모두 하나의 파일에 담는것이 코드 가독성에는 더 좋겠으나 외부파일 import를 해보고싶어 총 세개의 파일로 나누어 저장해보았다.
때문에 최종적으로 exe.py, getUnivCal.py, saveCal.py 세개의 파일을 가진 프로젝트로 마무리하였다.
결론 : 이게 이렇게나 큰공사였나..?
이렇게 작은 프로젝트가 마무리되고 글로 정리해보니 생각했던 규모보다 더 큰 작업이었구나 하는 생각이 든다.
간단한 크롭과 오픈API 사용은 익숙하였으나 예상치 못한 변수에 새로운 방향을 모색하여 원하는 기능을 구현 할 수 있었고, 그 과정에서 새로운 것을 배울 수 있었다.