👀 크롤링(스크래핑)으로 받아온 데이터를 활용한 토이프로젝트
학교 과제로 MERN 스택을 통해 크롤링한 웹페이지 만들기가 나왔다.
("크롤링"이라고 하셨는데 개념이 불분명해서 교수님께 여쭤 보니
"다른 웹페이지에 있는 정보를, 태그를 통해 가져오는 것"이라고 설명하셨다.
본 프로젝트에서 한 것은 하나의 웹페이지에서 정보를 받아오는 것 정도라서
스크래핑에 더 가까울 것 같다.)
약 4일에 걸쳐 개발했다.
다른 페이지에서 많은 정보를 가져와서 DB에 저장하고 검색해보는 것을 하기 위해 어떤 데이터를 집어넣을까... 하다가 동행복권 로또번호로 해보기로 했다.
로또번호로 한 이유는, 검색 조건에서 단순 일치하는 항목만 찾아보는 것보다는 1등, 2등, 3등 각각의 조건에 맞게 로직을 넣어서 검색해보고 싶었기 때문이다.
사실 동행복권 사이트에 들어가면 모든 회차 당첨번호와 당첨금을 액셀파일로 받아볼 수 있다. 그러나 크롤링이 필요한 과제였기 때문에 당첨금이 표로 정리된 블로그를 찾아서 수행했다.
Source : Github
(원래는 배포해서 링크 걸어두려고 했는데 배포에 실패한 관계로 UI만 소개한다.ㅜㅜ)
스크래핑 대상 사이트에 대해 get, DB 데이터를 프론트에 보내주는 get, 프론트의 입력값을 받아 DB를 검색하는 post를 사용하였다.
첫 번째 get은 스크래핑 대상 사이트에 get요청을 보내는 것이다. filldB()
함수는 DB에 스크래핑한 데이터를 집어넣는 역할이다. 바로 다음 항목에서 자세히 설명한다.
const fillDB = async()=>{
const html = await axios.get("https://signalfire85.tistory.com/28");
두 번째 get은 DB 데이터를 프론트에 보내주는 역할이다.
DB로부터 모든 데이터를 찾기 위해 조건 없이 find
를 해 준다.
그리고 데이터가 역순으로 정리되어 있어, 회차수를 기준으로 오름차순 정렬을 한다.
받아온 모든 데이터를 res.json
으로 보내준다.
app.get('/getAll',(req,res)=>{
Lotto.find().sort({'index':1}).exec((error, lotto)=>{
if (error){
console.log(error);
}else{
//console.log(lotto);
res.json(lotto);
}
});
})
post는 req.body
를 읽어와서 사용자 입력값을 array로 바꾼다. 프론트에서는 아래와 같이 데이터를 보낸다.
const json = await axios.post(SERVER_URL+'/numCheck', {inputs})
따라서 req.body
는 아래와 같은 형태이다.
{
inputs: [
9, 13, 21, 25,
32, 42, 2
]
}
여기서 내가 필요한 데이터는 req.body.inputs
라는 것을 알 수 있다.
app.use(express.json()) // req.body를 json 데이터로 바꾸어 준다. bodyParser과 같은 역할.
//...
app.post('/numCheck',(req,res)=>{
const inputs=(req.body.inputs);
//이하 생략
받아온 데이터를 구조에 맞게 저장하고 검색한다. mongoose 공식문서에 나오는 MongoDB 연결방법을 따라해준다.
const mongoose = require('mongoose');
mongoose
.connect(MONGO_URI)
.then(() => console.log('MongoDB conected'))
.catch((err) => {
console.log(err);
});
다음으로 스키마를 정의해준다. 스키마는 데이터 구조 ,즉 필드 타입에 관한 정보를 JSON 형태로 정의한 것으로 RDBMS의 테이블 정의와 유사한 개념이다. (출처) 스키마는 noSQL의 높은 자유도로 인해 원치않은 데이터까지도 validation 없이 저장되는 단점을 보완한다.
const lotto = mongoose.Schema({
index: Number,
date: String,
firstWinner: Number,
firstPrize: String,
winNum: [Number],
versionKey: false, //__v라는 필드가 자동으로 나타나는데 없애려면 이 속성을 추가해준다
});
const Lotto = mongoose.model('Schema', lotto);//스키마를 변수로서 사용
로또 번호 데이터를 받아오기 위해 html 태그를 이용한 웹 스크래핑을 한다.
먼저 스크래핑을 할 사이트의 html 태그 구조를 살펴본다. 개발자 도구(F12)를 켜서 필요한 요소의 태그를 확인한다.
tbody
의 child요소 중 tr
들을 가져오면 된다.
tr
의 하위 요소에는 어떻게 접근할까 고민해보았다. class 명으로 접근하기에는 공통점이 없는데도 겹치는 것이 있었다.
대신 데이터가 표로 정리되어 있어서 tr
child에는 모두 [회차, 추첨일, 당첨자수, 당첨금액, 당첨번호] 순으로 저장이 되어 있었다. 그래서 모든 tbody
> tr
요소들을 nth-of-type()
로 받아왔고 여기에 대해 forEach
문을 돌면서 데이터를 집어넣어주었다.
cheerio 사용법은 여기를 참고했다.
const cheerio = require("cheerio");
//...
const fillDB = async()=>{
const html = await axios.get("https://signalfire85.tistory.com/28");
const $ = cheerio.load(html.data);
const $bodyList = $("tbody").children("tr");
$bodyList.each((i, elm)=>{
if (i<=2) { return; } //첫두줄은 넣어야 할 내용 없음
elements=['',]; //index 0 있는 거 헷갈리니까 빈데이터 삽입
for(let i=1; i<=11; i++){
elements[i]=$(this).find(`td:nth-of-type(${i})`).text();
}
const newLotto = new Lotto({ //앞서 정의한 스키마에 따른 데이터 삽입
index: Number(elements[1]),
date: elements[2],
firstWinner: Number(elements[3]),
firstPrize: elements[4],
winNum: [
Number(elements[5]),
Number(elements[6]),
Number(elements[7]),
Number(elements[8]),
Number(elements[9]),
Number(elements[10]),
Number(elements[11]),
]
});
newLotto.save((error, data)=>{
if (error) console.log(error);
//else console.log(data);
});
});
}
그런데 이대로만 두면 웹페이지를 리로드할 때마다 get 해오고, 또 get 해오고... 해서 데이터가 누적되었다. 사실 처음 페이지를 로드할 때만 DB에 데이터를 넣어두면 된다.
const readDB = async()=>{
let promise = new Promise((res, rej)=>{
Lotto.find((error, data)=>{
if (data.length==0) {
res(true);
}
else res(false);
})
})
let empty = await promise;
if (empty){
fillDB();
}
}
서버에서 보내준 데이터를 받아온다.
모든 로또 당첨 정보를 받아오기만 할 때에는 get,
로또 당첨 정보를 검색할 때는 post를 이용했다.
1. get
/getAll로부터 모든 정보를 받아온다.
페이지가 처음 로드될 때만 실행하면 되므로 useEffect로 구현한다.
처음에는 이렇게 구현했다.
useEffect(async() => {
const json = await axios.get(SERVER_URL+'/getAll')
setLotto(json.data)
}, [])
그러나 위 코드를 사용하면 에러가 난다. 이유는 useEffect의 구조에 있다.
useEffect(()=>{
//마운트 시 실행
return ()=>{
//언마운트 시 실행 (클린업 함수)
}
}, [의존값])
async는 필연적으로 Promise를 반환한다. 보이지는 않지만 useEffect의 return문 안에 Promise가 들어가 버린다! 즉 클린업 함수에 원치 않은 값이 들어간다는 것이다.
따라서 useEffect 밖에서 함수를 선언하고, useEffect 안에서는 호출만 하는 식으로 사용해주어야 한다.
useEffect(() => {
getAll();
}, [])
const getAll=async()=>{
const json = await axios.get(SERVER_URL+'/getAll')
setLotto(json.data)
}
/numCheck에 사용자가 입력한 7자리 번호를 array로 만든 inputs
데이터를 보내서, inputs를 바탕으로 검색한 결과를 json으로 받아온다. 데이터를 받아오면 내 당첨결과를 myLotto
div 안에 보여준다. hide
는 css에서 display:none
해주어서 화면에 보이지 않게 하는 class로 썼다.
const sendNumbers=async(inputs)=>{
const json = await axios.post(SERVER_URL+'/numCheck', {inputs})
setMyLotto(json.data);
const message = document.getElementsByClassName("myLotto")[0];
message.classList.remove('hide');
}
많은 양의 데이터를 여러 페이지로 나누어 특정 페이지 번호로 넘어갈 수 있게 한다. react-js-pagination 라이브러리를 사용했다. (사용법 참고 블로그)
먼저 코드가 길어진 게 불편해서 페이지네이션은 모듈화했다. 아래에서 lotto 변수는 스크래핑으로 가져온 데이터 array이다. props로 ShowAll 컴포넌트에 상속해 주었다.
<ShowAll data={lotto}/>
function ShowAll(props){
const lotto=props.data;
///이하 생략
다음으로, react-js-pagination을 이용한 페이지네이션을 구현하기 위해, npm i react-js-pagination
과 import
를 해 주었다. 그리고 페이지네이션 컴포넌트를 불러왔다.
<Pagination
activePage={curpage}
itemsCountPerPage={10}
totalItemsCount={lotto.length}
pageRangeDisplayed={10}
onChange={handlePageChange}>
</Pagination>
프로퍼티의 의미는 각각 다음과 같다.
onChange 안의 함수에 의해 curpage 값이 변한다. 기본값이 1인 state로 정의해주고 함수에 따라 setState해준다. handlePageChange에서 e는 콘솔로 확인해본 결과 클릭한 페이지 넘버가 나타난다. 따라서 setCurpage(e)로 클릭한 페이지 넘버에 따라 curpage가 변화할 수 있도록 했다.
const [curpage, setCurpage] = useState(1);
const handlePageChange=(e)=>{
setCurpage(e);
}
다음으로 curpage에 따라 페이지에 보여주는 리스트를 바꿔준다.
10개씩 보여지도록 array.slice()
해주고 array.map()
을 통해 div을 생성한다.
{lotto.slice((curpage-1)*10, (curpage)*10).map((lotto)=>(
<div className="lottoEach">
<div>{lotto.index}회</div>
<div>{lotto.date}</div>
<div>{lotto.firstPrize}</div>
<div>{lotto.firstWinner}명</div>
<div>
{(lotto.winNum)&&lotto.winNum.map((n)=><span className="winNum">{n}</span>)}
</div>
</div>
))}
사용자 input값이 조건에 맞는지 확인한다.
required
속성을 추가해준다.<input className="numbers" type="text" required></input>
<form className="userinput" onSubmit={onSubmitHandler}>
///이하생략
onSubmitHandler 함수 내용은 다음과 같다.
입력한 7개의 숫자를 각각 newinput에 넣는다.
만약 newinput이 이미 inputs array에 포함돼 있거나, newinput이 1~45 사이가 아니라면
valerr 플래그를 true로 하고 반복을 멈춘다.
서로 다른 valmessage를 설정하려고 if문을 나누었다.
valerr 플래그가 false이면 valmessage를 지워준다.
const onSubmitHandler=(e)=>{
e.preventDefault();
const inputs=[];
let newinput = -1;
let valerr = false;
for (let i=0; i<7; i++){
newinput= Number(e.target.children[i].value);
if (inputs.includes(newinput)){
setValmessage("중복값이 존재합니다.")
valerr=true;
break;
}
if (newinput<1 || newinput > 45){
setValmessage("1~45 사이의 숫자를 선택하세요.")
valerr=true;
break;
}
inputs[i] = newinput;
}
if (!valerr) {
setValmessage("")
sendNumbers(inputs);
}
}
자동복권처럼, 1부터 45 사이의 난수를 생성해 input text 안에 넣어 준다.
버튼을 누르면 랜덤함수를 실행시키도록 한다.
<button className="random" onClick={putRandom}>자동</button>
putRandom 함수 내용은 다음과 같다.
javascript Math 라이브러리를 이용해 난수를 생성한다.
난수는 0~1 사이의 수이므로 100을 곱해 세 자리 수를 만들고,
45로 나눈 나머지에 1을 더해서, 1~45 사이의 숫자가 되도록 만든다.
만든 난수는 randomarr에 넣고 input value값으로 정해준다.
난수가 이미 randomarr에 있다면 넣지 않고 다시 난수를 생성하기 위해 인덱스를 1 줄여준다.
const putRandom=(e)=>{
e.preventDefault();
let newnum=0;
const input = document.getElementsByClassName("numbers");
let randomarr=[];
for (let i=0; i<7; i++){
newnum = Math.floor(Math.random()*100)%45+1;
if (!randomarr.includes(newnum)){
randomarr.push(newnum);
input[i].value=newnum;
}
else{
i--;
}
}
}
1등은 어지간히 운이 좋지 않은 이상 절대 안되는 모양이다... ㅎㅎ