Study for me
공군훈련소에서 인편으로 뉴스레터 받기 본문
코로나바이러스 때문에 한 학년을 마치지 못하고 헌혈까지 하며 급하게 공군을 준비했었는데, 운이 좋게도 이번에 제가 바라던 특기에 최종 합격했습니다.
군대 갈 날이 하루하루 다가오면서 미리 준비물이나 필요한 물품을 검색하던 도중에 "훈련소 뉴스레터"라는 서비스를 알게 되었습니다. 해당 서비스를 보니 5주간의 훈련소 기간동안 매일 뉴스들을 골라 훈련병들에게 인터넷 편지로 보내주는 시스템이었습니다.
훈련소에 들어가게 되면 핸드폰을 사용할 수 없기 때문에 굉장히 지루한데, 인터넷 편지라는 유일한 창구를 이용해서 뉴스레터를 보내준다는 점이 저에게는 굉장히 신선하고 재밌게 다가왔습니다.
하지만 안타깝게도 공군 기훈단이 목록에 없는 걸 보고 아직 공군을 지원하지 않는다는 걸 알게 되었습니다 (공군뿐만 아니라 해군이나 해병 훈련소도 선택이 안 되는 걸 보니 육군만 지원하는 것 같았습니다). 이대로 포기하기에는 너무 아쉬워서 직접 만들어보기로 했습니다. 마침 자바스크립트를 공부하고 있던 터라, Node.js를 사용해서 크롤링하면 가능할 것 같다는 생각이 들어 바로 코드 에디터를 켜고 만들어봤습니다.
프로젝트 구상과 구조
훈련소 뉴스레터의 메인 페이지에서는 다양한 뉴스들을 제공해주고 있기 때문에, 이 정보를 cheerio와 axios를 사용해 크롤링하기로 했습니다. 뉴스 같은 경우는 제가 관심 있는 해외축구와 e스포츠를 크롤링하도록 하겠습니다.
공군 기훈단 홈페이지에서는 직접 값을 집어넣어서 조작해야 되기 때문에 axios보다는 puppeteer를 사용하기로 했습니다.
아래는 대략적 폴더 구조입니다.
rokaf-atc-newsletter
├── components
│ ├── crawl
│ │ ├── getEsportsNews.js
│ │ ├── getHTML.js
│ │ └── getWorldSoccerNews.js
│ └── send
│ └── sendMessage.js
└── index.js
rokaf-atc-newsletter 폴더 안에 실행파일인 index.js 파일과 크롤링을 담당하는 crawl, 보내기를 담당하는 send 파일로 구조를 나눴습니다.
실행 플로우는 훈련소 뉴스레터에서 뉴스 크롤링 -> 크롤링 한 값을 저장 -> 저장한 값을 인편으로 보내기로 나눴습니다.
뉴스 크롤링하기
우선 components/crawl/getHTML.js 파일에서 axios를 사용해 페이지의 html을 가져옵니다.
// components/crawl/getHTML.js
const axios = require('axios');
const getHTML = async () => {
try {
return await axios.get('https://campnews21.com/', {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36',
'Content-Type': 'text/html',
},
Charset: 'utf-8',
});
} catch (errror) {
console.error(error);
}
};
module.exports = getHTML;
getHTML 함수를 export 해서 다른 곳에서도 쓸 수 있게 했습니다.
다음으로는 getHTML를 사용해서 해외축구 뉴스를 크롤링해보도록 하겠습니다.
// components/crawl/getWorldSoccerNews.js
const cheerio = require('cheerio');
const getHTML = require('./getHTML');
const getWorldSoccerNews = async () => {
const list = [];
const result = await getHTML().then((html) => {
const $ = cheerio.load(html.data);
const titleSelector = $(
'#modal-worldsoccer > div > div > div.modal-body.text-center > strong'
);
const contentSelector = $(
'#modal-worldsoccer > div > div > div.modal-body.text-center'
)
.contents()
.filter(function () {
return this.nodeType === 3;
});
const contentEach = contentSelector.each((i, element) => {
$(element).text();
});
titleSelector.each((i, element) => {
list[i] = {
title: $(element).text(),
content: contentEach[i].data,
};
});
});
return list;
};
module.exports = getWorldSoccerNews;
개발자 도구를 사용해서 선택자를 골라낸 후, cheerio를 사용해서 제목과 내용을 크롤링 한 뒤 list에 저장해서 return 했습니다. 이 getWorldSoccerNews 함수도 getHTML과 마찬가지로 함수를 export에서 다른 곳에서 사용할 수 있게 합니다.
// components/crawl/getEsportsNews.js
const cheerio = require('cheerio');
const getHTML = require('./getHTML');
const getEsportsNews = async () => {
const list = [];
const result = await getHTML().then((html) => {
const $ = cheerio.load(html.data);
const titleSelector = $(
'#modal-esports > div > div > div.modal-body.text-center > strong'
);
const contentSelector = $(
'#modal-esports > div > div > div.modal-body.text-center'
)
.contents()
.filter(function () {
return this.nodeType === 3;
});
const contentEach = contentSelector.each((i, element) => {
$(element).text();
});
titleSelector.each((i, element) => {
list[i] = {
title: $(element).text(),
content: contentEach[i].data,
};
});
});
return list;
};
module.exports = getEsportsNews;
e스포츠 뉴스 크롤링도 해외축구 뉴스 크롤링과 같습니다. 선택자만 조금 수정했습니다.
그리고 이제 index.js에서 두 함수를 가져옵니다.
// index.js
const getWorldSoccerNews = require('./components/crawl/getWorldSoccerNews');
const getEsportsNews = require('./components/crawl/getEsportsNews');
const worldSoccerNews = getWorldSoccerNews();
const esportNews = getEsportsNews();
worldSoccerNews.then(function (value) {
console.log(value);
});
esportNews.then(function (value) {
console.log(value);
});
console.log를 찍어보면 크롤링했던 값들이 배열에 담겨있는 걸 확인할 수 있습니다. 이제 크롤링 작업을 끝냈으니 인편을 보내는 것만 남았습니다.
크롤링한 뉴스 보내기
브라우저의 DOM을 직접 조작해야 돼서 axios가 아닌 puppeteer를 사용했습니다.
공군 기훈단 홈페이지에 들어가서 input 선택자들을 미리 찾아줍니다.
편지를 보내려면 이름 그리고 생년월일이 필요한 것 같습니다. 머릿속으로 첫 번째 플로우를 그려보자면 이름 입력하기 -> 생년월일 입력하기 -> 교육생 검색 버튼 누르기가 필요합니다.
교육생을 검색하게 되면, 교육생의 신상정보가 담긴 부분과 선택하기 버튼이 있는 팝업이 하나 생기게 됩니다. 이 팝업에서 선택하기 버튼을 누르게 되면 팝업은 사라지고 원래 창으로 돌아와서 교육생 검색 버튼이 편지 쓰기 버튼으로 바뀌어 있습니다. 이 편지 쓰기 버튼을 누르게 되면 해당 교육생의 게시판이 뜨는 걸 확인할 수 있습니다.
편지 쓰기 버튼을 누르면 작성 창이 나오는데, 그제야 뉴스레터를 보낼 수 있습니다.
주소, 발신자 이름과 관계는 크게 중요하지 않을 것 같고 중요한 부분은 바로 내용입니다. 내용 부분을 조작해 크롤링했던 list를 문자열 형태로 넣어주면 될 것 같습니다.
// components/crawl/sendMessage.js
const puppeteer = require('puppeteer');
const sendMessage = async (letterTitle, list) => {
const browser = await puppeteer.launch({
headless: true,
});
const page = await browser.newPage();
const name = process.env.TRAINEE_NAME;
const birthYear = process.env.TRAINEE_BIRTH_YEAR;
const birthMonth = process.env.TRAINEE_BIRTH_MONTH;
const birthDay = process.env.TRAINEE_BIRTH_DAY;
await page.goto(
'https://atc.airforce.mil.kr:444/user/indexSub.action?codyMenuSeq=156893223&siteId=last2'
);
await page.evaluate(
(name, birthYear, birthMonth, birthDay) => {
document.querySelector('#searchName').value = name;
document.querySelector('#birthYear').value = birthYear;
document.querySelector('#birthMonth').value = birthMonth;
document.querySelector('#birthDay').value = birthDay;
},
name,
birthYear,
birthMonth,
birthDay
);
await page.click('#btnNext');
// 팝업 시작
const [popup] = await Promise.all([
new Promise((resolve) => page.once('popup', resolve)),
]);
await popup.waitForSelector('#emailPic-container > ul > li > input');
await popup.click('#emailPic-container > ul > li > input');
// 팝업 끝
await page.click('#btnNext');
await page.waitForSelector(
'#emailPic-container > div.UIbtn > span > input[type=button]'
);
await page.click(
'#emailPic-container > div.UIbtn > span > input[type=button]'
);
await page.waitForSelector(
'#emailPic-container > form > div.UIview > table > tbody > tr:nth-child(3) > td > div:nth-child(1) > span > input'
);
await page.click(
'#emailPic-container > form > div.UIview > table > tbody > tr:nth-child(3) > td > div:nth-child(1) > span > input'
);
// 도로명주소 팝업 시작
const [newPopup] = await Promise.all([
new Promise((resolve) => page.once('popup', resolve)),
]);
// // 팝업 에러부분 무시 (headless: false일때만)
// await newPopup.click('#proceed-button');
await newPopup.waitForSelector('#keyword');
const address = process.env.SENDER_ADDRESS;
await newPopup.evaluate((address) => {
document.querySelector('#keyword').value = address;
}, address);
await newPopup.click(
'#searchContentBox > div.search-wrap > fieldset > span > input[type=button]:nth-child(2)'
);
await newPopup.waitForSelector('#roadAddrTd1 > a');
await newPopup.click('#roadAddrTd1 > a');
const detailedAddress = process.env.SENDER_DETAILED_ADDRESS;
await newPopup.evaluate((detailedAddress) => {
document.querySelector('#rtAddrDetail').value = detailedAddress;
}, detailedAddress);
await newPopup.waitForSelector('#resultData > div > a');
await newPopup.click('#resultData > div > a');
// 도로명주소 팝업 끝
const senderName = process.env.SENDER_NAME;
const relationship = process.env.SENDER_RELATIONSHIP;
const title = letterTitle;
let content = '';
const password = process.env.SENDER_PASSWORD;
for (let i = 0; i < 10; i++) {
content += `${list[i].title}-${list[i].content}`;
}
await page.evaluate(
(senderName, relationship, title, content, password) => {
document.querySelector('#senderName').value = senderName;
document.querySelector('#relationship').value = relationship;
document.querySelector('#title').value = title;
document.querySelector('#contents').value = content;
document.querySelector('#password').value = password;
},
senderName,
relationship,
title,
content,
password
);
await page.waitForTimeout(500);
await page.click(
'#emailPic-container > form > div.UIbtn > span.wizBtn.large.Ngray.submit > input'
);
await browser.close();
};
module.exports = sendMessage;
제목과 내용을 제외한 발신자 이름, 관계 그리고 비밀번호는 환경변수를 사용해서 언제든지 변경할 수 있게 했습니다. 그리고 dotenv 모듈을 사용해서. env 파일에 환경변수를 정의했습니다.
sendMessage도 크롤링 함수들과 마찬가지로 index.js에 import 할 것이기 때문에 함수로 만들어 export 해줍니다.
그 후, index.js를 아래와 같이 바꿔줍니다.
// index.js
require('dotenv').config();
const getWorldSoccerNews = require('./components/crawl/getWorldSoccerNews');
const getEsportsNews = require('./components/crawl/getEsportsNews');
const sendMessage = require('./components/send/sendMessage');
const worldSoccerNews = getWorldSoccerNews();
const esportNews = getEsportsNews();
worldSoccerNews.then(function (value) {
sendMessage('오늘의 해외 축구 뉴스', value);
});
esportNews.then(function (value) {
sendMessage('오늘의 이스포츠 뉴스', value);
});
프로젝트 완성
이제 index.js를 실행해보면 정상적으로 보내지는 걸 확인할 수 있습니다.
글자 수 제한이 있어서 끝 부분이 잘리는 게 조금 아쉽습니다.
속도도 대략 7~8초 정도 걸립니다. 느릴 줄 알았는데 막상 실행해보니 굉장히 빠릅니다.
입대하기 전에 aws에 코드를 올려놓고 crontab를 사용해서 실행시킬 예정입니다. 오류가 생기면 프로그램이 죽어버려 뉴스레터가 전송되지 않기 때문에 굉장히 두렵습니다.
그리고 이번에 자바스크립트로 처음 크롤링을 도전해봤는데, 파이썬의 bs4와 다르게 굉장히 사용하기가 까다로웠습니다. 비동기로 작성해줘야 하므로 온 군대에 async/await을 붙이는데 이것 때문에 문제가 발생하면 상당히 골치 아팠습니다. 자바스크립트를 공부하는 중이기 때문에 토이 프로젝트를 만들어볼 겸 언어를 자바스크립트로 선택했었는데 나중에 비슷한 프로젝트를 만들 때는 자바스크립트 대신 파이썬을 쓸 것 같습니다.
해당 프로젝트는 여기서 확인하실 수 있습니다.