이번 포스트에서는 제어되는 그 고루틴에서 돌아가는 runCrawler 함수에 대해서 설명하고자 한다. 특히 python selenium의 대체제로 사용될 수 있는 chromedp 라이브러리를 사용한 코드에 대해서 더 자세하게 설명하고자 한다. (chromedp와 관련된 한국어 자료가 없어서 꽤나 고생했다) 아직까지는 한국어로 된 chromedp 사용법, 특히 goquery와 chromedp를 조합하는 포스트가 많이 없는 것 같아서, 이 기회에 정리해두려고 한다.
오늘 설명할 (내가 짠) 코드 전체
// subway_information에서 각 호선의 네이버코드와 역이름 정보 가져오기 -> 해당 역이름 페이지로 접속하여 HTML 획득 -> crawler로 스크래핑
func runCrawler(val map[string]interface{}, baseURL string, lineNum string) {
// val안의 값들은 interface이기 때문에 type assertion 필요
naverCode := int(val["naverCode"].(float64)) // 왜인지 모르겠지만 처음 파일에서 interface로 값 가져올 때 float64로 가져와짐
stationNm := val["stationNm"].(string)
URL := baseURL + strconv.Itoa(naverCode) + "/home"
// 타깃 페이지에 접속하여 outerHMTL 획득
htmlContent := getHTMLContents(URL)
// 페이지 소스 크롤링 & 필요한 정보 정리하기
crawler(htmlContent, lineNum, stationNm)
}
// chromedp 설정 & 홈페이지 접속 후 시간표 탭 클릭하여 타겟 페이지 접속
func getHTMLContents(URL string) string {
// settings for crawling
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("single-process", true),
chromedp.Flag("no-zygote", true),
)
alloCtx, _ := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(alloCtx, chromedp.WithLogf(log.Printf))
defer cancel()
var htmlContent string
ch := chromedp.WaitNewTarget(ctx, func(i *target.Info) bool {
return strings.Contains(i.URL, "/timetable/web/")
})
// 크롤링 대상 페이지에 접속하기 위해 URL 접속 -> 클릭
err := chromedp.Run(ctx,
chromedp.Navigate(URL),
// 클릭해야 할 부분이 나올때까지 기다리기
chromedp.WaitVisible(".end_footer_area"),
chromedp.Click("body > div.app > div > div > div > div.end_section.station_info_section > div.at_end.sofzqce > div > div.c10jv2ep.wrap_btn_schedule.schedule_time > button"),
)
checkErr(err)
// 클릭으로 새로운 탭이 생긴 곳으로 컨텍스트 옮기기 -> OuterHTML 추출
newContext, cancel := chromedp.NewContext(ctx, chromedp.WithTargetID(<-ch))
defer cancel()
if err := chromedp.Run(newContext,
chromedp.WaitReady(".table_schedule", chromedp.ByQuery),
chromedp.OuterHTML(".schedule_wrap", &htmlContent, chromedp.ByQuery),
); err != nil {
panic(err)
}
return htmlContent
}
// 타깃 페이지 HTML에서 필요한 정보만 추출 후 정리하기
func crawler(htmlContent string, lineNum string, stationNm string) {
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
// weektag 알아내기 & d에 기록
tag := doc.Find(".c1hj6oii.c92twem.btn_day.is_selected").Text()
switch tag {
case "평일":
tag = "1"
case "토요일":
tag = "2"
case "공휴일":
tag = "3"
}
// 시간표를 순회하며 inoutTag와 arriveTime 알아내기 -> 필요한 정보 가공 후 map 형태로 저장
doc.Find(".table_schedule > tbody > tr").Each(func(i int, tr *goquery.Selection) {
tr.Find("td").Each(func(j int, td *goquery.Selection) {
tmp := td.Find(".inner_timeline > .wrap_time > .time")
var arriveTime string
// 만약 빈 박스일 경우 무시 (for문 안이 아니라 continue는 못 씀)
if tmp.Text() == "" {
return
}
// arriveTime 정보 기록
arriveTime = tmp.Text() + ":00"
// inOutTag 정보 기록
var inOutTag string
switch j {
case 0:
inOutTag = "1" // 1: 상행
case 1:
inOutTag = "2" // 2: 하행
}
// 필요한 정보 모두 기록 & concurrent map writes 에러를 피하기 위한 mutex 설정
mutex.Lock()
subwayData = make(map[string]string)
subwayData["lineNum"] = lineNum
subwayData["stationNm"] = stationNm
subwayData["weekTag"] = tag
subwayData["arriveTime"] = arriveTime
subwayData["inOutTag"] = inOutTag
data = append(data, subwayData)
mutex.Unlock()
})
})
fmt.Println(lineNum, "호선 ", stationNm, " - 입력 완료")
}
참고로 위 코드는 아래 과정을 코드화한 것이다.
- getHTMLContents 함수: 1번, 2번 과정을 거쳐 역 시간표 페이지에 진입, 역 시간표 페이지의 HTML을 추출해온다
- crawler 함수: getHTMLContents로 추출해온 HTML을 파싱, 원하는 정보만 추출하여 내가 원하는 형식([]map[string]string)으로 정리한다
TMI - Chromedp을 선택하기 전까지의 여정
시작 전에, 왜 chromedp를 사용했는지부터 얘기하고 넘어가야 할 것 같다. python selenium 사용이 익숙한터라, go에서도 selenium을 사용하고자 했다. 하지만,,, golang에서의 selenium은 마지막 업데이트가 21년 11월이었다(심지어 README 업데이트임)... 또한 selenium을 사용하기 위해선 webdriver를 반드시 사용해야 하는데, 이 또한 업데이트 되지 않고 방치되고 있는 상태였다.
그래도 혹시 모르는 마음에 코드를 짜서 시도를 해보았다. 우선 지금되면 장땡이니까?
하지만 결과는 좋지 않았다.
첫번째 시도: chrome driver + selenium
크롬 브라우저의 버전 문제 때문에 실패하였다. 코드 실행 중 에러가 발생하여 분석해보니 chromedriver를 쓰기 위해서는 크롬을 110버전으로 다운그레이드 해야 하는 상황이었다. 열심히 서칭해보며 로컬의 크롬을 지웠다가 다시 깔았다가 열심히 반복해봤지만,,, 잘 되지 않았다. 개인적인 추측으로는 webdriver 라이브러리가 업데이트 되지 않아 라이브러리에서 사용하는 크롬 버전 업데이트도 멈춘 것이 아닌가 싶다.
두번째 시도: Gecko + selenium
크롬이 문제라면 파이어폭스로 해보면 되지 않을까라는 생각을 하게 되어 시도했다. 근데 이것도 잘 안 됐다... 왜 안 됐는지는 분명 에러랑 이유, 코드 캡쳐 해둔 것 같은데 없어져서 기억이 안 난다. 역시 기록은 미리미리 해야...
세번째 시도: webdriver만 써보기
열심히 서칭해보니 webdriver만으로 코드를 짜신 분이 계셔서 해당 방법을 따라해보았다. 하지만 이 역시 크롬 버전이었나, 클릭 이벤트 처리가 안 되었나 여튼 잘 안 되어 포기
Chromedp 소개
돌고돌아 chromedp로 왔다. chromedp는 Brankas라는 기업에서 만들고 관리하고 있는 라이브러리이다. 2017년 싱가폴에서 열린 고퍼콘 자료를 보니 기존 selenium의 한계를 극복하기 위해 만든 것 같았다. selenium의 경우 webdriver를 함께 이용해야 하는데, chrome webdriver를 설치하고 사용하는 과정 중에서 굉장히 많은 에러가 발생한다. 또한, webdriver는 싱글 스레드만 지원하기 때문에 병렬 처리를 위해서는 브라우저를 여러 개 띄우는 방식으로 진행해야 하고, 브라우저를 low-level 단에서 건드릴 수 없기 때문에 한 번에 만들어낼 수 있는 크롬 인스턴스의 갯수가 제한적이다.
chromedp는 별도의 webdriver 없이(!) chromium 기반으로 제작된 브라우저들에서 모두 사용할 수 있다. 또한 브라우저의 low-level에서 작동하기 때문에 세세한 컨트롤이 가능하며(chrome devtool에서 할 수 있는 것이라면 다 할 수 있다), selenium 스타일의 action과 task를 제공하여 기존 selenium 유저들도 친숙하게 쓸 수 있다는 장점을 가지고 있다. 또한 headless-shell에서 돌리기 위한 의존성과 go 바이너리들을 이것저것 합해도 100메가 바이트 안으로 배포할 수 있다는 장점도 있다.
간단한 Chromedp 사용법
우선 chromedp를 다운받아 코드를 작성할 환경을 준비하자
$ go get -u github.com/chromedp/chromedp
가장 기본적으로 NewContext()를 이용하여 chromedp context를 초기화한다. (chromedp context에 이것저것 설정하고, 이를 바탕으로 이후에 Run에서 생성된 브라우저 혹은 탭을 이것저것 조작한다고 생각하자) cancel은 브라우저(혹은 탭)에 할당된 리소스가 더 이상 필요 없을 때 release하는 역할을 한다.
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
NewContext는 부모 context를 상속받는데, 만약 부모 컨텍스트에서 브라우저를 포함하고 있다면 이후 뒤에서 설명할 Run 함수를 사용할 때 브라우저에 탭을 추가적으로 생성하게 하고, 그렇지 않다면 run에서 새로운 브라우저에 탭을 생성하게 만드는 역할을 한다. (주의할점! NewContext 자체가 브라우저를 allocate하거나 생성하는 것이 아니라, NewContext로 만든 context에서 Run이 실행될 때 그렇다는 의미다. '컨텍스트'라는 단어의 의미를 잘 생각하자)
// new browser, first tab
ctx1, cancel := chromedp.NewContext(context.Background())
defer cancle()
// create first tab
if err := chromedp.Run(ctx1); err != nil{
log.Fatal(err)
}
// same browser, second tab
ctx2, _ := chromedp.NewContext(ctx1)
// create second tab
if err := chromedp.Run(ctx2); err != nil{
log.Fatal(err)
}
Run 함수를 사용하면 만들어진 context를 바탕으로 브라우저 혹은 탭을 띄우고 actions를 실행시킬 수 있다. action은 Action 타입으로 만든 여러 타입(NavigateAction, PollAction, QueryAction 등)을 반환하는 함수라고 생각할 수 있다. Screenshot, Click, DoubleClick, SendKeys, Submit, Text와 같이 스크래핑, 프로파일링에 필요한 이벤트들이 함수로 잘 구현되어 있으니 공식 문서를 확인하여 필요한 것들을 조합하여 써보자. (변수에 스크린샷, 텍스트 등의 결과물을 저장하고 싶은 경우 Run 전에 변수를 미리 만들어두고 Action 안에 변수를 입력하는 방식으로 사용하는 것 같았다)
var picBuf []byte
var strVar string
err := chromedp.Run(ctx,
chromedp.Navigate(URL), // 입력한 URL로 접속
chromedp.WaitVisible(sel), // 처음 매칭된 element가 나올때까지 기다리기
chromedp.Click(sel), // 처음 매칭된 element node에 클릭 이벤트 전송
chromedp.Screenshot(sel, &picBuf), // 처음 매칭된 element node 스크린샷 후 picBuf에 저장
chromedp.Text(sel, &strVar), // 처음 매칭된 element node의 visible text strVar에 저장
)
Tasks 타입으로 여러 action을 묶어 하나의 커스텀한 action을 만들 수 있다.
if err := chromedp.Run(ctx,
elementScreenshot(URL, sel, &buf)
); err != nil {
log.Fatal(err)
}
func elementScreenshot(urlstr, sel string, res *[]byte) chromedp.Tasks {
return chromedp.Tasks {
chromedp.Navigate(urlstr),
chromedp.Screenshot(sel, res, chromedp.NodeVisible),
}
}
혹은 ActionFunc 어덥터를 사용하여 새로운 함수를 action으로 사용할 수도 있다
chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
node, err := dom.GetDocument().Do(ctx)
if err != nil {
return err
}
res, er := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx)
if er != nil {
return er
}
return res
}))
action 함수들은 보통 css selector를 입력하여 사용하지만, By~ 함수를 인자로 넣어 ID, JSPath, NodeID 등을 기준으로 매칭을 걸 수도 있다. 어떤 종류가 있는지는 공식 문서 참고
if err := chromedp.Run(ctx,
chromedp.Navigate(URL),
chromedp.Click("#newtab", chromedp.ByID),
); err != nil {
log.Fatal(err)
}
조금 더 심화된 내용 - Allocator
NewContext()로 등으로 만들어내는 Context 타입은 브라우저를 생성 관리하는 Allocator, 브라우저 프로세스 러너와 웹소켓 클라이언트 네트워크와 페이지, DOM 이벤트를 핸들링하는 Browser, Chrome DevTool 프로토콜 타겟을 관리하는 Target, 구체적인 브라우저 컨텍스트 안에서 새로운 타겟의 컨텍스트를 생성하기 위한 BrowserContextID로 이루어진 구조체이다.
type Context struct {
Allocator Allocator
Browser *Browser
Target *Target
BrowserContextID cdp.BrowserContextID
}
NewContext에 브라우저 실행과 운영을 커스텀하고 싶을 경우 Allocator를 조작하는 방법도 있는데, 이를 위해선 NewExecAllocator를 사용하여 변수를 생성한뒤, NewContext에 넘겨주면 된다. 주로 DefaultExecAllocatorOptions (별도의 옵션 추가 없이 NewContext로 context를 생성할 경우 세팅되는 allocator 옵션들)에 원하는 옵션을 추가하는 방식으로 코드를 작성한다. AllocatorOption의 경우 chromdep에서 제공하는 옵션도 있지만, Chrome에 존재하는 flag들을 이용할 수 있다. Chorme 혹은 Chromium에 대한 이해가 깊을수록 사용할 맛이 날 것 같긴 하다.
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.DisableGPU,
chromedp.NoSandbox,
)
alloCtx, _ := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(alloCtx, chromedp.WithLogf(log.Printf))
defer cancel()
DafaultExecAllocatorOptions는 아래와 같이 구성되어 있으며, 공식 문서에 보면 아래 옵션은 건들지 말라고 적혀 있다.
var DefaultExecAllocatorOptions = [...]ExecAllocatorOption{
NoFirstRun,
NoDefaultBrowserCheck,
Headless,
Flag("disable-background-networking", true),
Flag("enable-features", "NetworkService,NetworkServiceInProcess"),
Flag("disable-background-timer-throttling", true),
Flag("disable-backgrounding-occluded-windows", true),
Flag("disable-breakpad", true),
Flag("disable-client-side-phishing-detection", true),
Flag("disable-default-apps", true),
Flag("disable-dev-shm-usage", true),
Flag("disable-extensions", true),
Flag("disable-features", "site-per-process,Translate,BlinkGenPropertyTrees"),
Flag("disable-hang-monitor", true),
Flag("disable-ipc-flooding-protection", true),
Flag("disable-popup-blocking", true),
Flag("disable-prompt-on-repost", true),
Flag("disable-renderer-backgrounding", true),
Flag("disable-sync", true),
Flag("force-color-profile", "srgb"),
Flag("metrics-recording-only", true),
Flag("safebrowsing-disable-auto-update", true),
Flag("enable-automation", true),
Flag("password-store", "basic"),
Flag("use-mock-keychain", true),
}
조금 더 심화된 내용 - 특정 element를 click한 후 생성된 새로운 페이지로 진입하기
전짜기 진행한 타겟은 그대로 내비둔 채, 새로운 탭을 열어 새로운 타겟을 찾기 위해선 WaitNewTarget이라는 함수가 필요하다. (아직 온전히 이해한 것은 아니지만) 어떤 context가 생성되면 그 안 Target에 프로토콜 타겟?들의 리스트들이 저장이 되고, context와 context 간 target 정보를 주고 받을 일이 있을 때 WaitNewTarget으로 channel을 생성하는 것이 아닌가 싶었다.
여튼 NewTarget으로 context를 초기화 -> WaitNewTarget 함수로 channel을 생성-> 이전 생성한 context를 바탕으로 click을 포함한 Run 실행 -> click으로 새롭게 띄워진 탭을 조작할 context 새로 생성 (이때 이전 context와 channel을 인자로 넣어줌) 하는 방법으로 사용할 수 있다.
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
// 현재 타깃이 URL에 "/timetable/web"이 들어가는 타깃을 열 때까지 대기
// 매칭되는 타겟이 나오면 타깃id가 채널로 전송됨
ch := chromedp.WaitNewTarget(ctx, func(info *target.Info) bool {
return strings.Contains(info.URL, "/timetable/web/")
})
if err := chromedp.Run(ctx,
chromedp.Navigate(URL),
chromedp.Click(sel),
); err != nil {
log.Fatal(err)
}
newCtx, cancel := chromedp.NewContext(ctx, chromedp.WithTagetID(<-ch))
defer cancel()
if err := chromedp.Run(newCtx
, chromedp.Text(sel, &varStr)
); err != nil {
log.Fatal(err)
}
내 코드 설명
이제 내가 chromedp와 goquery를 어떻게 조합하여 사용했는지 설명하겠다.
우선 chromedp 자체만으로 모든 것을 할 수 있었지만, 테이블 형태로 되어 있는 복잡한 구조의 데이터를 스크래핑하는데는 goquery가 훨씬 더 직관적이고 쉽게 코드를 짜는 것이 가능했다. 따라서 chromedp로는 클릭 이벤트 처리, 페이지 진입, outerHTML 추출을 했고, 추출된 outerHTML을 파싱하여 원하는 형태로 가공하는 것은 goquery를 사용하여 진행하였다.
chromedp로 OuterHTML을 추출하기
// chromedp 설정 & 홈페이지 접속 후 시간표 탭 클릭하여 타겟 페이지 접속
func getHTMLContents(URL string) string {
// settings for crawling
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("single-process", true),
chromedp.Flag("no-zygote", true),
)
alloCtx, _ := chromedp.NewExecAllocator(context.Background(), opts...)
// allocator 옵션 받아서 context 초기화
ctx, cancel := chromedp.NewContext(alloCtx, chromedp.WithLogf(log.Printf))
defer cancel()
var htmlContent string
// 새로운 페이지 진입, target 설정을 위한 채널 생성
ch := chromedp.WaitNewTarget(ctx, func(i *target.Info) bool {
return strings.Contains(i.URL, "/timetable/web/")
})
// 크롤링 대상 페이지에 접속하기 위해 URL 접속 -> 클릭
err := chromedp.Run(ctx,
chromedp.Navigate(URL),
// 클릭해야 할 부분이 나올때까지 기다리기
chromedp.WaitVisible(".end_footer_area"),
chromedp.Click("body > div.app > div > div > div > div.end_section.station_info_section > div.at_end.sofzqce > div > div.c10jv2ep.wrap_btn_schedule.schedule_time > button"),
)
checkErr(err)
// 클릭으로 진입한 새로운 탭에 대한 context 생성
newContext, cancel := chromedp.NewContext(ctx, chromedp.WithTargetID(<-ch))
defer cancel()
if err := chromedp.Run(newContext,
// 테이블 정보가 나올때까지 대기, outerhtml 추출
chromedp.WaitReady(".table_schedule", chromedp.ByQuery),
chromedp.OuterHTML(".schedule_wrap", &htmlContent, chromedp.ByQuery),
); err != nil {
panic(err)
}
return htmlContent
}
Flag 옵션 조합의 경우 사실 의미가 있다기 보다는 (이후에도 얘기하겠지만) headless-shell (chromedp의 도커 이미지)로 만든 docker image를 Lambda에서 돌아가게 만들기 위해 여러가지 시도하다가 우연히 작동한 조합이다.
많은 사람들이 chromedp를 도커라이즈하여 lambda위에서 돌리기 위해 무수히 많은 flag 설정 실험을 하고 끝끝내 실패 했는데, 나는 그들의 정보를 받아 여러가지 실험하다가 우연히 한 조합이 얻어 걸려 사용하게 되었다. 이 썰은 이후 다른 포스트에서 적도록 하겠다.
- 참고자료 1: Problem with aws lambda
- 참고자료 2: chromedp-aws-lambda-example
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
// 더 찾아보니 single-process는 없어도 되는 것 같기도 하고
chromedp.Flag("single-process", true),
chromedp.Flag("no-zygote", true),
)
내 코드를 짤 때 클릭 후 새로운 페이지 진입에서 많은 고생하고 많이 좌절했다. 분명 클릭하여 새로운 탭을 열리는 것까지 확인했는데 데이터가 안 담겨서 저장되었던... 삽질 하다가 도저히 안 될 것 같아서 포기해야 하나 싶다가 stackoverflow에 질문을 올렸는데 다행히 답변이 달렸다. 정성스러운 답변을 주신 Zeke Lu님께 감사를... (https://stackoverflow.com/questions/76152907/chromedp-click-is-not-working-in-my-golang-code-can-you-find-whats-wrong)
// allocator 옵션 받아서 context 초기화
ctx, cancel := chromedp.NewContext(alloCtx, chromedp.WithLogf(log.Printf))
defer cancel()
var htmlContent string
// 새로운 페이지 진입, target 설정을 위한 채널 생성
ch := chromedp.WaitNewTarget(ctx, func(i *target.Info) bool {
return strings.Contains(i.URL, "/timetable/web/")
})
// 크롤링 대상 페이지에 접속하기 위해 URL 접속 -> 클릭
err := chromedp.Run(ctx,
chromedp.Navigate(URL),
// 클릭해야 할 부분이 나올때까지 기다리기
chromedp.WaitVisible(".end_footer_area"),
chromedp.Click("body > div.app > div > div > div > div.end_section.station_info_section > div.at_end.sofzqce > div > div.c10jv2ep.wrap_btn_schedule.schedule_time > button"),
)
checkErr(err)
// 클릭으로 진입한 새로운 탭에 대한 context 생성
newContext, cancel := chromedp.NewContext(ctx, chromedp.WithTargetID(<-ch))
defer cancel()
if err := chromedp.Run(newContext,
// 테이블 정보가 나올때까지 대기, outerhtml 추출
chromedp.WaitReady(".table_schedule", chromedp.ByQuery),
chromedp.OuterHTML(".schedule_wrap", &htmlContent, chromedp.ByQuery),
); err != nil {
panic(err)
}
goquery로 OuterHTML 파싱하고 데이터 정리하기
// 타깃 페이지 HTML에서 필요한 정보만 추출 후 정리하기
func crawler(htmlContent string, lineNum string, stationNm string) {
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
// weektag 알아내기 & d에 기록
tag := doc.Find(".c1hj6oii.c92twem.btn_day.is_selected").Text()
switch tag {
case "평일":
tag = "1"
case "토요일":
tag = "2"
case "공휴일":
tag = "3"
}
// 시간표를 순회하며 inoutTag와 arriveTime 알아내기 -> 필요한 정보 가공 후 map 형태로 저장
doc.Find(".table_schedule > tbody > tr").Each(func(i int, tr *goquery.Selection) {
tr.Find("td").Each(func(j int, td *goquery.Selection) {
tmp := td.Find(".inner_timeline > .wrap_time > .time")
var arriveTime string
// 만약 빈 박스일 경우 무시 (for문 안이 아니라 continue는 못 씀)
if tmp.Text() == "" {
return
}
// arriveTime 정보 기록
arriveTime = tmp.Text() + ":00"
// inOutTag 정보 기록
var inOutTag string
switch j {
case 0:
inOutTag = "1" // 1: 상행
case 1:
inOutTag = "2" // 2: 하행
}
// 필요한 정보 모두 기록 & concurrent map writes 에러를 피하기 위한 mutex 설정
mutex.Lock()
subwayData = make(map[string]string)
subwayData["lineNum"] = lineNum
subwayData["stationNm"] = stationNm
subwayData["weekTag"] = tag
subwayData["arriveTime"] = arriveTime
subwayData["inOutTag"] = inOutTag
data = append(data, subwayData)
mutex.Unlock()
})
})
fmt.Println(lineNum, "호선 ", stationNm, " - 입력 완료")
}
추출한 HTML 안에는 데이터가 아래와 같이 다소 복잡한 표 형식으로 존재하고 있었다.
전체 표인 tbody - 상행선 하행선을 포함하는 한 줄인 tr -> 한 줄 안에 상하행선 각각 박스로 되어 있는 td로 이루어져 있었다. goquery에서 each를 두 번 사용하여 각각의 td 박스 안에 접근할 수 있도록 코드를 작성하였다
// 시간표를 순회하며 inoutTag와 arriveTime 알아내기 -> 필요한 정보 가공 후 map 형태로 저장
doc.Find(".table_schedule > tbody > tr").Each(func(i int, tr *goquery.Selection) {
tr.Find("td").Each(func(j int, td *goquery.Selection) {
tmp := td.Find(".inner_timeline > .wrap_time > .time")
위 이미지에서도 볼 수 있듯 간혹 빈 박스가 있는 경우가 있는데, 이 경우는 무시하였다. For문이 아닌 if 문 안이기 때문에 return 값을 아무것도 주지 않는 것으로 이를 해결하였다
// 만약 빈 박스일 경우 무시 (for문 안이 아니라 continue는 못 씀)
if tmp.Text() == "" {
return
}
정보들의 경우 switch 문을 사용하여 정리하였고, 맨 마지막에는 map 형태로 최종 취합하였다. 이때 mutex를 사용하여 map의 atomic한 write를 보장하고자 하였는데 이와 관련해서는 아래 포스트에 더 자세히 적어두었다