내가 아주 기본적인 토대를 짜고, 그 위에 팀원 분이 아주 멋지게 코드를 짜주어 여차저차해서 크롤링 코드의 초안이 나름대로 완성되었다. 이 코드를 개선하는 과정을 운 좋게 내가 담당하게 되었는데, 그 과정에서 굉장히 많은 고민을 해서 기록을 남긴다. 오류를 해결하기 위해 아이디어를 생각해내는 순간들, 어떻게 하면 더 효율적으로, 더 외부환경에 강건한 크롤러를 만들 수 있을까에 대한 고민을 하는 과정이 너무나 재미있었다.
1. 전체적인 구조 개선
1) 시간표에 접근하는 전략 수정하기: 네이버의 역코드를 알아낼 수 있는 크롤러 별도 제작
(1) 문제 인식
개선 전 코드의 기본 흐름은 아래와 같다 (이미지도 첨부한다)
1) 네이버 모바일 지도에 검색 쿼리를 날린다
2) 가장 위에 있는 검색 결과를 클릭한다
3) 역별 상세 페이지에 접근한다
4) '전체 시간표'를 클릭하여 시간표 페이지로 진입한다
5) 홈페이지 내용을 크롤링한다
그런데 짜놓고 보니 온전히 코드로만은 네이버에서 제공하는 지하철 시간표를 크롤링 할 수 없었다. 가장 큰 이유는 첫 번째 검색결과가 꼭 해당 지하철, 전철 정보가 나오는 것이 아니기 때문이었다. 아래 예시처럼 시청역 2호선이라고 검색하면 을지로입구역에 대한 정보가 첫번째로 나오기도 하고, 아예 역이 아니라 음식점 정보가 나오기도 했다. 첫번째 검색 결과로 가장 연관성이 큰 해당 호선의 해당 역에 대한 데이터 링크가 나올 것이라는 가정이 기각되는 순간이었다.
이로 인해 오류로 코드가 중단되기도 했고, none값이나 엉뚱한 데이터가 쌓이는 경우도 꽤 있었다. 따라서 사람이 수동으로 조정해야 하는 파트들이 꽤 있었다.
매일매일 돌려 시간표를 얻어야 하는 상황에서 이는 치명적인 결함이었다. 이를 해결하기 위한 과정에서 꽤 많은 방법을 찾아봤던 것 같다.
(2) 해결 방법 도출 - 전체적인 아이디어
우리가 시도한 것은 1~5번 과정 중 1, 2번을 뛰어넘고 3번으로 바로 진입하는 방법이다. 즉, 네이버 맵에서 검색 쿼리를 날리고 첫번째 검색 결과를 클릭하는 과정 그 자체를 생략하는 것이다. 사실 이전에 바로 3번으로 바로 진입할 수도 있다는 사실을 알고는 있었다.
https://pts.map.naver.com/end-subway/ends/web/201/home
위 사진에 해당하는 페이지로 진입할 수 있는 URL 중 201은 지하철 역 코드이다. 사실 저 부분의 숫자를 바꿔주면 다른 역의 검색 결과로 바로 진입할 수 있다는 사실을 코드 초안을 짜기 전에 인지하고 있었다.
우리가 이 지하철 역 코드를 사용하지 못한 이유는, 이 역 코드가 도로교통공사 등에서 널리 쓰이는 코드가 아닌, 네이버에서 자체적으로 설정한 코드이기 때문에, 각 숫자가 어떤 호선의 어떤 역을 나타내는지 몰랐기 때문이다. 아무리 찾아봐도 왜 시청역이 201인지, 1호선 소요산역은 101번이고 1호선 송탄역은 1401번인지 알 수 있는 방법이 없었다.
어느정도의 규칙성은 존재하긴 했지만(101번이 1호선의 첫역, 102번이 1호선의 두 번째 역 등) 중간중간 역 매칭이 없는 번호도 있고, 9호선 외에 신림선, 분당선, 신분당선 등등은 시작번호가 몇 번인지 가늠하기도 쉽지 않았다.
이 규칙성만 알아내면 검색결과로 우회해서 들어가는 것이 아니라 바로 역 상세 정보로 진입할 수 있는데 그 방법을 모르는 상태였다. 이를 어떻게 알아낼까... 규칙성을 연구해내볼까 들여다보기도 하고 네이버맵 api를 뒤져보기도 하고 하다가...
결국 생각해낸 것은 무식하게 다 검색해보는 것이었다. 어짜피 이 시간표는 하루에 한 번만, 첫 차가 시작하기 전에 크롤링하면 되어 천천히 크롤링해도 될 것이다. 시간적인 여유가 있는 상황이기도 하고, 그리고 나중에 어떤 호선에 어떤 역이, 어떤 신규 호선이 신설될지 모르니 항상 새롭게 코드를 찾아내는 것이 좋을 것 같다는 판단이 있었다.
(3) 해결 방법 도출 - 구현
결론적으로는 아래 흐름으로 역 코드를 알아내는 함수를 짜기로 결정했다.
1) 100부터 20000까지 쿼리를 날려
2) 역 정보가 있는지를 검사하고
3) 없으면 pass, 있으면 호선 이름과 역 이름을 크롤링
4) json 형식으로 이를 정리한다
위 함수는 각 역으로 접속해 시간표 정보를 크롤링하는 것과는 다른 태스크이기 때문에 별도의 스크립트 파일로 작성하는 것이 좋다고 판단하였다. 즉, 전체적인 흐름이 아래와 같이 수정되었다.
1) 네이버 역 코드를 알아내는 함수로 (호선, 역이름, 네이버 역 코드)가 정리된 'subway_information' json 파일을 만든다. 뒤에서는 편의상 네이버 코드 크롤러, 혹은 find_code 파일이라고 부르겠다.
2) 'subway_information' json 파일의 역 정보를 바탕으로 크롤링 코드를 돌려 각 호선별, 역별 시간표 정보를 크롤링한다.. 뒤에서는 역 시간표 크롤러라고 부르겠다.
2) 메모리 더 효율적으로 사용하기 & 모든 상황에 강건하게 대응할 수 있는 크롤러 짜기
팀원이 전달해준 코드를 보다보니 크롤링을 본격적으로 시행하기 전, 크롤링을 할 모든 호선과 모든 역을 리스트 안에 정렬하는 작업이 있는 것을 발견했다. 아래 코드는 언뜻보면 매우 비효율적일 수 있지만, 팀에서 기존에 가지고 있던 데이터를 이용하기 위해서는 아래 코드가 가장 적합한 코드였다. 데이터 예시를 코드 바로 아래 첨부한다.
with open('subway.json', 'r', encoding='utf-8') as f:
# Load the contents of the file as a Python object
data = json.load(f)
line_1 = []
line_2 = []
line_3 = []
line_4 = []
line_5 = []
line_6 = []
line_7 = []
line_8 = []
line_9 = []
line_bundang = []
line_gyeongui = []
line_airport = []
line_sinbundang = []
line_wui = []
line_gyeongchun = []
for i in data['DATA']:
if i['line_num'] == '01호선':
line_1.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '02호선':
line_2.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '03호선':
line_3.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '04호선':
line_4.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '05호선':
line_5.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '06호선':
line_6.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '07호선':
line_7.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '08호선':
line_8.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '09호선':
line_9.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '수인분당선':
line_bundang.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '경의선':
line_gyeongui.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '공항철도':
line_airport.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '신분당선':
line_sinbundang.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '경춘선':
line_gyeongchun.append((i['station_cd'], i['station_nm']))
elif i['line_num'] == '우이신설경전철':
line_wui.append((i['station_cd'], i['station_nm']))
(데이터 일부)
{
"DESCRIPTION" : {"STATION_NM":"전철역명","STATION_CD":"전철역코드","STATION_NM_CHN":"전철명명(중문)","LINE_NUM":"호선","FR_CODE":"외부코드","STATION_NM_JPN":"전철명명(일문)","STATION_NM_ENG":"전철명명(영문)"},
"DATA" : [
{"line_num":"02호선","station_nm_chn":"龍踏","station_cd":"0244","station_nm_jpn":"龍踏","station_nm_eng":"Yongdap","station_nm":"용답","fr_code":"211-1"},
{"line_num":"02호선","station_nm_chn":"新踏","station_cd":"0245","station_nm_jpn":"新踏","station_nm_eng":"Sindap","station_nm":"신답","fr_code":"211-2"},
{"line_num":"02호선","station_nm_chn":"龍頭","station_cd":"0250","station_nm_jpn":"龍頭","station_nm_eng":"Yongdu","station_nm":"용두","fr_code":"211-3"},
{"line_num":"03호선","station_nm_chn":"Hangnyeoul","station_cd":"0336","station_nm_jpn":"ハンニョウル","station_nm_eng":"Hangnyeoul","station_nm":"학여울","fr_code":"346"},
{"line_num":"04호선","station_nm_chn":"三角地","station_cd":"0428","station_nm_jpn":"三角地","station_nm_eng":"Samgakji","station_nm":"삼각지","fr_code":"428"},
{"line_num":"04호선","station_nm_chn":"新龍山","station_cd":"0429","station_nm_jpn":"新龍山","station_nm_eng":"Sinyongsan","station_nm":"신용산","fr_code":"429"},
{"line_num":"경의선","station_nm_chn":"二村","station_cd":"1008","station_nm_jpn":"二村","station_nm_eng":"Ichon","station_nm":"이촌","fr_code":"K111"},
{"line_num":"경의선","station_nm_chn":"淸凉里","station_cd":"1014","station_nm_jpn":"淸凉里","station_nm_eng":"Cheongnyangni","station_nm":"청량리","fr_code":"K117"},
{"line_num":"01호선","station_nm_chn":"溫陽溫泉","station_cd":"1407","station_nm_jpn":"溫陽溫泉","station_nm_eng":"Onyang oncheon","station_nm":"온양온천","fr_code":"P176"},
{"line_num":"04호선","station_nm_chn":"倉洞","station_cd":"0412","station_nm_jpn":"倉洞","station_nm_eng":"Chang-dong","station_nm":"창동","fr_code":"412"},
{"line_num":"04호선","station_nm_chn":"吉音","station_cd":"0417","station_nm_jpn":"吉音","station_nm_eng":"Gireum","station_nm":"길음","fr_code":"417"},
{"line_num":"수인분당선","station_nm_chn":"往十里","station_cd":"102C","station_nm_jpn":"往十里","station_nm_eng":"Wangsimni","station_nm":"왕십리","fr_code":"K210"},
{"line_num":"수인분당선","station_nm_chn":"水西","station_cd":"1030","station_nm_jpn":"水西","station_nm_eng":"Suseo","station_nm":"수서","fr_code":"K221"},
{"line_num":"경의선","station_nm_chn":"楊平","station_cd":"1217","station_nm_jpn":"楊平","station_nm_eng":"Yangpyeong","station_nm":"양평","fr_code":"K135"},
즉, 데이터 자체가 호선 별로 정렬되지도 않았고, 영문명 중문명 등 우리 프로젝트에서는 사용하지 않는 정보들이 있는 혼돈의 카오스 그런 느낌이었다.
가장 큰 목표는 아래 두 가지 개선 사항을 적용시키는 것이었다.
1) 모든 호선과 거기에 속해있는 역이 이미 정리되어 있도록 데이터를 만들자.
2) 미리 호선을 정의해둘 경우, 호선 자체가 신설되었을 때를 대응하지 못하니 호선을 사용자가 미리 정의하지 않는 방향으로 개선해보자 (즉, 데이터에 따라 자동적으로 호선과 역이 업데이트 될 수 있도록 구조를 바꿔보자)
결국 'subway_information' json 안에 있는 데이터가 어떠어떠한 형식을 띄어야 한다는 것이 가장 중요한 요소가 되었다. 즉, 네이버 코드를 가져오는 코드를 통해서 호선, 역이름, 역코드를 가져오되, 이후에 사용할 크롤링 코드가 효율적으로 참조할 수 있는 형태로 output을 내주어야 한다는 것이었다.
우선 1번 요구사항을 만족하기 위해서, 키를 "DESCRPITION"과 "DATA"가 아닌 호선 이름이 될 수 있도록 데이터 형식을 바꿨다. 이때 키값, 즉 호선의 이름은 사람이 미리 정의하는 것이 아니라 네이버 코드를 가져오는 과정 중에서 코드가 네이버 검색결과에서 발견한 호선 이름을 가져와서 그것을 바탕으로 자동으로 정의되게 설정하였다. 이렇게 함으로써 호선 자체가 신설되더라도 사람이 대응하는 것이 아닌, 코드가 자동적으로 대응할 수 있게 되었다.
# 네이버 코드 가져오는 find_code 함수
def find_code():
INFO = {}
for naver_code in range(100, 400):
base_query = "https://pts.map.naver.com/end-subway/ends/web/{naver_code}/home".format(naver_code=naver_code)
page = requests.get(base_query)
soup = BeautifulSoup(page.text, "html.parser")
try:
line_num = soup.select_one('body > div.app > div > div > div > div.place_info_box > div > div.p19g2ytg > div > button > strong.line_no').get_text()
station_nm = soup.select_one('body > div.app > div > div > div > div.place_info_box > div > div.p19g2ytg > div > button > strong.place_name').get_text()
except:
continue
# 호선 자체가 새로 생기는 경우가 있을 것 같아 호선을 미리 정의하지 않고, 크롤링 결과에 의해 정의되게 함
if line_num not in INFO:
INFO[line_num] = []
block = {"station_nm": station_nm, "naver_code": naver_code}
INFO[line_num].append(block)
return INFO
또한 각 호선에 대한 value로 리스트를, 그리고 그 리스트 안에 {역이름, 네이버 코드}가 들어가는 방식으로 데이터 구조를 설정했다. 이렇게 함으로써 json 파일에서부터 호선별로 역이 정리되어 있게 하였으며, 키를 이용하여 간단하게 해당 호선에 대한 역을 모두 가져올 수 있게 하였다. (대충 아래와 같은 느낌이다)
{
"1호선": [
{
"station_nm": "소요산역",
"naver_code": 100
},
{
"station_nm": "동두천역",
"naver_code": 101
},
{
"station_nm": "보산역",
"naver_code": 102
},
{
"station_nm": "동두천중앙역",
"naver_code": 103
}
],
"경의중앙선": [
{
"station_nm": "청량리역",
"naver_code": 191
},
{
"station_nm": "왕십리역",
"naver_code": 192
},
{
"station_nm": "응봉역",
"naver_code": 193
},
위 과정을 거치면 for 와 elif 문이 엄청 많던 코드를 아래와 같이 심플하게 바꿀 수 있다
# 라인, 역이름, 네이버코드 불러오기
with open('subway_information.json', 'r', encoding='utf-8') as f:
# Load the contents of the file as a Python object
data = json.load(f)
target_lines = data.keys()
크롤링이든 데이터 엔지니어링이든 결국 처음부터 데이터를 어떤 형식으로 쌓을 것인가가 중요하다는 것을 몸소 체험하는 순간이 아니었나 싶다. "어떠어떠한 프로젝트를 할 것이고, 어떠어떠한 프로세스로 엔지니어링을 진행할 것이니, 어떤 형태의 데이터가 인풋으로 들어오면 좋을 것 같다"라는 합의가 미리 있으면 정말 좋을 것 같다. (물론 회사 사정상, 혹은 현실이 그러기 쉽다는 것은 알고 있긴 하다...)
2. 자잘자잘한 수정 및 알게 된 것들
1) 예외 잘 처리해주기
네이버 역 코드를 찾는 함수의 경우 필연적으로 에러를 뱉을 수 밖에 없다. 역 코드가 아닌 번호로 쿼리를 날리게 되면 검색 결과가 없을 것이기 때문이다. 따라서 try except를 이용하여 빈 페이지에 접근하더라도 함수의 작동이 멈추지 않고 계속 돌아가게 끔 예외처리를 해주어야 한다.
for naver_code in range(100, 1000):
base_query = "https://pts.map.naver.com/end-subway/ends/web/{naver_code}/home".format(naver_code=naver_code)
page = requests.get(base_query)
soup = BeautifulSoup(page.text, "html.parser")
try:
line_num = soup.select_one('body > div.app > div > div > div > div.place_info_box > div > div.p19g2ytg > div > button > strong.line_no').get_text()
station_nm = soup.select_one('body > div.app > div > div > div > div.place_info_box > div > div.p19g2ytg > div > button > strong.place_name').get_text()
except:
continue
처음에는 except 다음에 어떤 걸 적어줘야 하는지 잘 몰랐다. 처음에는 pass를 적어주었다가 낭패를 보았던 기억이 있다. 분명 역코드가 없는 숫자일텐데 그 숫자에 대한 정보가 json 파일에 저장되는 것을 보고 기겁했던 생각이 난다.
알고보니 pass 외에도 continue와 break를 함께 쓸 수 있다는 것을 알게 되었다.
- pass: 실행할 코드가 없는 것으로 다음 행동을 계속해서 진행
- continue: 바로 다음 순번의 반복을 수행
- break: 반복문을 중단하고 loop 밖으로 나가도록 함
작성한 크롤러의 코드 구조 상 pass보다는 continue가 더 맞는 처리라고 판단하여 적용하였고, 정확한 결과를 얻을 수 있었다
2) 꼭 필요한 데이터만 들어오게 하기
기존의 역 시간표 크롤링 코드의 경우 평일, 토요일, 공휴일에 상관없이 모든 시간표가 다 크롤링되었다. 또한, 첫차 막차의 경우 한 번 더 중복되어 들어오고 있었다. 시간표를 크롤링하는 이유는 해당 날짜의 시간표가 필요하기 때문이다. 따라서 이 크롤링 코드는 하루에 한 번씩 돌아가게 될 것이며, 따라서 굳이 평일, 토요일, 공휴일 시간표 모두가 필요하지 않다. 평일에는 평일, 토요일에는 토요일, 공휴일에는 공휴일 시간표만 필요하다. 또, 어느날 갑자기 시간표가 수정되는 경우가 있을 수 있으므로 미리 시간표를 크롤링하는 것은 데이터의 정확성을 떨어뜨릴 수 있는 위험을 증가시키기도 할 것이다.
+ 우리는 크롤링한 날짜, 시간표의 날짜가 평일인지, 토요일인지, 공휴일인지에 대한 정보가 반드시 필요했다. 이에 대한 정보를 가져오는 작업을 추가적으로 진행하는 것이 필수적이었다.
이는 시간표 페이지에서 평일, 토요일, 공휴일을 나타내는 버튼이 어떤 방식으로 활성화되는지 관찰한 후 해결방법을 도출하였다. 시간표 페이지에 접근을 했을 때, 만약 그 날이 평일이라면 평일 텍스트에, 토요일이라면 토요일 텍스트에 'aria-selected="true"'가 적용된다.
따라서, 크롤링하는 입장에서는 해당 시간표 페이지에 접근하고, aria-selected가 true된 텍스트만 찾는다면 그 날이 어떤 날짜인지 알아낼 수 있으며, 해당 부분에 대한 시간표만 크롤링할 수 있다. 작성한 코드는 아래와 같다
# week_tag 알아내기 (평일, 토요일, 공휴일&일요일)
day = soup.find(attrs={"aria-selected" : "true"}).get_text()
if day == '평일':
week_tag = 1
elif day == '토요일':
week_tag = 2
elif day == '공휴일':
week_tag = 3