[Chapter 3] HTML 스크래핑 하기
HTML은 어디서 가져와야 하나?
Cheerio의 getting started를 따라가면서 이것으로 가상 DOM을 조작해 데이터를 뽑아낼 수 있음을 알 수 있었다.
그렇다면 크롤링할 HTML 문서는 어떻게 가져와야 할까?
만약 내가 특정 사이트에 진입하면 내 컴퓨터(클라이언트)는 서버에 사이트를 렌더링 하는 데 필요한 데이터를 요청한다.
이때 렌더 트리를 구성하기 위한 HTML 파일이 오게 되는데, 이것이 우리가 스크래핑할 HTML 문서다.
상명대학교 공식 홈페이지를 통해서 이를 확인해 보자.
아래는 https://www.smu.ac.kr/ko/index.do 주소로 사이트에 접속했을 때 일어나는 네트워크 요청을 개발자 도구로 확인한 사진이다.
'헤더'의 '일반' 항목을 보면 요청 URL 주소를 확인할 수 있고, '응답' 항목을 보면 응답으로 온 것이 사이트의 HTML임을 알 수 있다.
즉, 우리 프로그램에서 같은 URL로 요청을 보내면 공식 사이트 메인 페이지의 HTML 구조를 받아올 수 있구나, 생각할 수 있다.
Axios
위와 같이 HTML 파일을 가져오려면 적절한 url로 HTTP 요청을 보내야 한다.
나는 이를 위해서 Axios를 사용했다. 설치는 Chapter 2에서 진행했으므로 넘어간다.
URL을 만들자
HTTP 요청을 하기 위해서는 요청 URL이 필요하다.
내가 원하는 사이트는 공지사항 페이진데, 오늘 올라온 공지사항 외에는 필요 없으므로 필터링을 해야 한다.
공지사항 페이지는 검색을 위한 필터링 옵션을 제공하고 있는데, 이를 url의 쿼리 스트링으로 전달하고 있었다.
날짜를 지정하는 srStartDt와 srEndDt의 형식이 20XX-XX-XX 였으므로 url을 만들기 위해서는 날짜를 포맷에 맞게 반환하는 함수가 필요했다.
그래서 date.js 파일을 따로 만들어 이를 구현해 줬다.
// date.js
const today = new Date();
export const year = today.getFullYear();
export const month = today.getMonth() + 1;
export const date = today.getDate();
export const formattedDate = `${year}-${month < 10 ? `0${month}` : `${month}`}-${date < 10 ? `0${date}` : `${date}`}`;
// index.js
// 가독성을 위해 base url과 query를 분리.
const SMUOfficialBaseURL = `https://www.smu.ac.kr/lounge/notice/notice.do`;
const SMUOfficialQuery = `?srcCampus=smu&srStartDt=${getFormattedDate()}&srEndDt=${getFormattedDate()}&mode=list&srCategoryId1=&srSearchKey=&srSearchVal=`;
axios.get(`${SMUOfficialBaseURL}${SMUOfficialQuery}`)
.then((res) => {
console.log(res.data);
})
데이터를 확인하기 위해 console.log를 찍었는데 HTML 문서 위로 CDATA라는 엄청난 가독성의 텍스트가 같이 오는 것을 볼 수 있었다.
해당 데이터가 cheerio로 요소를 탐색할 때 방해가 되진 않을까 걱정했는데, CDATA 자체는 텍스트 데이터이기 때문에
HTML이나 XML 문서를 탐색하는 cheerio 함수엔 방해가 되지 않는다고 한다.
데이터를 뽑자!
요소를 선택하려면 CSS 선택자가 필요하다.
이번에 구현하면서 알게 된 꿀팁인데, 개발자 도구엔 CSS 선택자를 복사하는 기능이 있다!
공지사항은 ul 태그 안에 각각 li로 오고 있었다. 따라서 ul 태그의 selector를 복사해 상수로 저장하여 이용했다.
그 안에 있는 category, title, views 그리고 상세 페이지의 url을 찾기 위한 selector도 각각 상수로 저장해 가독성을 높이려 노력했다.
요소를 선택했으면 cheerio의 API인 each()로 li들을 돌면서 text() 와 attr()를 사용해서 데이터를 뽑는다!
해당 함수는 jQuery에도 존재하는 함수로, 각각 태그 안에 있는 텍스트와 태그의 속성을 가져온다.
완성된 index.js 전체 코드는 이런 모양!
// selectors
const listSelector = "#ko > div.board-name-thumb.board-wrap > ul";
const categorySelector = "dl > dt > table > tbody > tr > td:nth-child(2) > a > span.cate";
const titleSelector = "dl > dt > table > tbody > tr > td:nth-child(3) > a";
const viewsSelector = "dl > dd > ul > li.board-thumb-content-views";
axios.get(`${SMUOfficialBaseURL}${SMUOfficialQuery}`)
.then((res) => {
if(res.status === 200) {
const $ = cheerio.load(res.data); // full html doc
const $noticeList = $(listSelector).children('li');
let todaysNoticeList = [];
$noticeList.each(function (idx, notice) {
const category = $(notice).find(categorySelector).text();
const title = $(notice).find(titleSelector).text();
const views = $(notice).find(viewsSelector).text();
const url = $(notice).find(titleSelector).attr('href');
todaysNoticeList[idx] = {
category: category,
title: removeEscapeChar(title),
views: removeEscapeChar(views),
url: `${SMUOfficialBaseURL}${url}`
}
})
return todaysNoticeList;
}
})
.catch((err) => { console.log(err) })
// white space 제거하고 공백 요소 제거하기
const removeEscapeChar = (str) => {
let arr = str.split('\n');
let cleanedStr = arr.map((item) => {
return item.trim();
}).filter((item) => {
return item !== '';
})
return cleanedStr;
}
text()로 뽑은 데이터엔 불필요한 \n, \t와 같은 white space가 너무 많아서 해당 요소를 제거할 필요가 있었다.
가장 아래에 있는 removeEscapeChar 가 그런 역할을 한다.
trim()은 이걸 구현하면서 처음 알았는데, 문자열 양 끝의 공백을 제거하는 함수라고 한다.
trim()으로 white space를 모두 제거하고, filter로 ''만 담겨 있는 요소를 모두 제거해 반환했다.
짠. 드디어 데이터가 정상적으로 나온다!
다음 챕터에선 이렇게 모은 데이터를 이메일로 보내는 기능을 구현한다.