먹고 기도하고 코딩하라

Node.js로 카카오톡 학식 채팅봇 만들기 (2) - 크롤링 스크립트 작성, 발화에 대응하는 앱 짜기 본문

개발일지

Node.js로 카카오톡 학식 채팅봇 만들기 (2) - 크롤링 스크립트 작성, 발화에 대응하는 앱 짜기

사과먹는사람 2020. 8. 27. 11:58
728x90
728x90

이전 글 보기

 

Node.js로 카카오톡 학식 채팅봇 만들기 (1)

원래 Python3 + Django2 조합으로 카카오톡 챗봇을 만들어 운영하고 있었는데요. (관련 튜토리얼) 이제 노드에 전념해보고자 기존에 운영하던 챗봇을 Node.js로 똑같이 구현해보기로 결심했습니다. 바�

dev-dain.tistory.com

 

저번에 '테스트'를 입력했을 때 메아리를 치는 채팅봇을 만들었습니다. 이제 용도에 따라 구체화를 시켜야겠죠? 저는 또 우리 학교 학식봇을 만들어 보겠습니다.

 

학교 홈페이지를 크롤링해야 하는데, 크롤링을 할 수 있는 모듈로 가장 유명한 것은 cheerio입니다. 하지만 cheerio는 동적 웹페이지를 제대로 크롤링하지 못한다는 단점이 있습니다. 그래서 puppeteer도 함께 이용할 것입니다.

github cheerio 프로젝트의 README 문서를 읽어보면 cheerio의 사용법은 터무니없을 정도로 간단합니다. cheerio에서는 일반적으로 '$'를 셀렉터로 삼습니다. 제이쿼리에서 쓰는 셀렉터와 똑같죠. 문서를 읽는 것도 좋고 이 블로그에 정리가 잘 되어있으니 참고하셔도 좋겠습니다.

 


 

1. 크롤링

웹페이지를 크롤링하는 전체 코드는 다음과 같습니다.

//crawl.js
const puppeteer = require('puppeteer');
const cheerio = require('cheerio');
const fs = require('fs');
const mealObj = [];

(async() => {
  try {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto('http://www.duksung.ac.kr/diet/schedule.do?menuId=1151');
    const content = await page.content();
    const $ = cheerio.load(content);

    const dayList = $("#schedule-table > thead > tr > th");
    dayList.each((index, list) => {
      if ($(list).find('br').length) {
        $(list).find('br').replaceWith('\n');
      }
      const day = $(list).text();
      mealObj.push({ day });
    });
    mealObj.shift();

    const mealList = $("#schedule-table > tbody > tr > td");
    mealList.each((index, list) => {
      if ($(list).find('br').length) {
        $(list).find('br').replaceWith('\n');
      }
      const meal = $(list).text();
      if (index < 5) {
        mealObj[index].student = meal;
      } else if (index === 10) {
        mealObj.unshift({ meal });
      } else {
        mealObj[index % 5].staff = meal;
      }
    });

    fs.writeFile('./meal.json', JSON.stringify(mealObj), (err) => {
      if (err) throw err;
      console.log('OK');
    });

    await browser.close();
  } catch (err) {
    console.error(err); 
  }
})();

 

ES2015와 async, await를 어느 정도 이해하셨다면 코드를 읽기 그리 어렵지 않으실 겁니다.

처음에 try 블록으로 감싸주지 않아서 await에서 Promise reject가 될 경우를 고려하지 않았는데요. 지금과 같이 try-catch 블록으로 감싸줘야 에러 핸들링이 가능합니다.

async 다음의 5번째 줄까지는 puppeteer를 런칭하고 목표 URL의 웹페이지를 동적으로 읽어와 cheerio로 로드하는 과정입니다. puppeteer는 Headless Chrome, Chromium을 제어하도록 하는 모듈인데요. Headless Chrome은 EC2처럼 GUI가 아닌 CLI 환경에서 브라우저와 웹페이지를 다뤄야 할 때 유용합니다. 파이썬으로 만들었을 때는 firefox를 설치하고 코드 내에서 headless 옵션을 줬던 기억이 나는데요. puppeteer를 설치하면서 chromium까지 한 번에 설치되기에 그 외에 따로 뭘 설치할 필요가 없습니다.

(추가!) 만약 코드를 이렇게 쓰시고, puppeteer도 잘 설치되었는데 에러가 뜬다면 다음 포스팅을 참고해보세요.

 

 

ubuntu에서 puppeteer 실행 오류

ubuntu에서 puppeteer 실행 오류

velog.io

cheerio 셀렉터로 목표물인 태그를 선택해 줍니다. 제 경우 #schedule-table 에서 th까지 차근차근 내려갔는데 th 태그가 6개였습니다. 이 th 태그들에 대해 <br> 태그가 있으면 전부 개행으로 바꿔주는 작업을 먼저 했습니다. 따로 어디 할당해줄 필요 없이 replaceWith을 쓰면 바로 태그 내용이 변경됩니다. 그 다음 text()를 해서 태그의 텍스트만 긁어서 day라는 키를 줘서 객체를 만들어 미리 만들어둔 mealObj라는 배열에 넣었습니다.

마지막에 shift한 이유는 첫 번째 th 태그가 '구분'이라는 쓸모없는 내용이기 때문입니다.

그 외의 다른 5개 th 태그들은 요일과 날짜 정보를 담고 있는 유용한 태그들입니다.

이제 mealObj는 다음과 같습니다.

const mealObj = [
  { day: "월요일\n8월 24일" },
  { day: "화요일\n8월 25일" },
  { day: "수요일\n8월 26일" },
  { day: "목요일\n8월 27일" },
  { day: "금요일\n8월 28일" }
]

 

그 다음 tbody 쪽으로 넘어가보겠습니다. cheerio 셀렉터로 목표물인 태그를 선택해 줍니다. tbody 밑의 td들은 총 11개였습니다. 여기서도 <br> 태그가 있다면 개행으로 바꿔준 다음 텍스트만 빼냈습니다.

그 다음 인덱스에 따라 key를 달리 줬는데요. 5번보다 아래, 즉 0~4번까지의 5개는 student라는 key를 주고 5~9번까지는 staff 키를 줬습니다. 지금 mealObj 안에는 객체 요소가 5개 있고 첫 번째 student(0)와 첫 번째 staff(5)는 똑같은 월요일 식사이므로 student는 그냥 index번째 객체에 프로퍼티를 삽입했고 staff는 index % 5번째 객체에 삽입했습니다.

10번은 meal이라는 key를 줘서 바로 unshift했습니다. 10번만 특별하게 취급한 까닭은 10번이 요일과 상관없는 일주일 상시 메뉴이기 때문입니다. unshift를 한 까닭은 나중에 설명하겠지만 자바스크립트의 Date 객체의 요일을 나타나는 getDay()는 일요일부터 0번으로 시작해서 월-금요일까지 1-5번이기 때문이죠.

 

이제 mealObj는 다음과 같습니다.

const mealObj = [
  { meal: '상시메뉴' },
  { day: '8월 24일\n월요일', student: '월요일 학생식당밥', staff: '월요일 교직원식당밥' },
  { day: '8월 25일\n화요일', student: '화요일 학생식당밥', staff: '화요일 교직원식당밥' },
  { day: '8월 26일\n수요일', student: '수요일 학생식당밥', staff: '수요일 교직원식당밥' },
  { day: '8월 27일\n목요일', student: '목요일 학생식당밥', staff: '목요일 교직원식당밥' },
  { day: '8월 28일\n금요일', student: '금요일 학생식당밥', staff: '금요일 교직원식당밥' }
];

 

이 mealObj를 JSON 파일로 저장하고 마치겠습니다. 물론 후처리가 필요하지만 크롤링 스크립트 따로 후처리 스크립트 따로 작성하기로 하겠습니다. fs 모듈을 불러와 writeFile해준 다음 열어뒀던 browser를 닫아 줍니다.

 

 

2. 후처리

후처리 스크립트를 작성해 보겠습니다.

//process.js
const fs = require('fs');

function changeMsg(meal) {
  const msg = '학식이 없는 날이거나 홈페이지에 등록되지 않았습니다.\n';
  return (meal.length < 2) ? msg : meal;
}

let mealObj;

fs.readFile('./meal.json', 'utf8', (err, data) => {
  if (err) throw err;
  mealObj = JSON.parse(data);
  
  mealObj[0].meal = '\n*상시메뉴\n'.concat(changeMsg(mealObj[0].meal)); 
  for (let i = 1; i < mealObj.length; i++) {
    mealObj[i].student = '\n*학생식당\n'.concat(changeMsg(mealObj[i].student));
    mealObj[i].staff = '\n*교직원식당\n'.concat(changeMsg(mealObj[i].staff));
  }

  fs.writeFile('./meal.json', JSON.stringify(mealObj), (err) => {
    if (err) throw err;
    console.log('OK');
  });
});

 

아까 crawl.js에서 생성했던 파일 meal.json을 불러옵니다. 

changeMsg라는 함수는 meal: String을 받아 length가 2 미만이면 학식이 없다는 메시지를, 2 이상이면 원래 문자열을 그대로 돌려줍니다. 이 함수를 이용해 학식이 없는 방학 기간이나 개교기념일, 명절 등 학식이 없는 날을 조회해봤을 때 학식이 없다는 메시지를 보여주는 것이 목표입니다.

mealObj 배열 요소로 루프를 돌린 다음 다시 저장합니다. 간단하죠?

이제 배시 창에서 두 스크립트를 돌려주면 JSON 파일이 생성되고 후처리까지 됩니다.

$ node crawl.js
$ node process.js

 

 

3. 사용자의 발화에 대응하기

이제 JSON 파일이 준비되었으니 app.js 파일에서 사용자의 발화에 따라 내용을 달리 보여주게 하는 작업이 필요합니다. app.js 파일을 열고 일단 모듈 require한 바로 밑에 JSON 파일을 불러와서 meal에 할당해줍니다.

//app.js
//...
const app = express();

let meal;
fs.readFile('./meal.json', 'utf8', (err, data) => {
  if (err) throw err;
  meal = JSON.parse(data);
});

app.use(express.urlencoded({ extended: false }));
//...

 

이것 말고 수정해야 할 부분은 app.post('/message', (req, res) => { }) 부분입니다. 한 번 고쳐봅시다.

app.post('/message', (req, res) => {
  const day = new Date();
  const today = day.getDay();
  const yoil = day.toString().slice(0, 3).toUpperCase();

  const question = req.body.userRequest.utterance;
  const goMain = '처음으로';
  const goBack = '뒤로가기';
  const selectDay = '요일지정';
  let data;
  //...
});

 

'오늘', '내일'이라는 발화에 대응하기 위해 '/message' 경로로 POST 요청이 왔을 때의 시간을 캐치해 객체로 만드는 Date 객체를 하나 만들고, 요일을 나타내는 수와 영문을 긁어옵니다. 오늘이 수요일이라면 today는 3, yoil은 'WED'가 됩니다.

전에도 말씀드렸던 것처럼 req.body.userRequest.utterance가 사용자 발화입니다. 밑에 goMain, goBack, selectDay는 제가 임의로 설정해둔 숏컷입니다. 

분기 로직은 여러 개가 있지만 코드 길이가 좀 있는만큼 오늘, 상시메뉴, 요일을 선택했을 때 정도만 글에서 다루겠습니다. 전체 코드는 여기서 볼 수 있습니다.

라고 썼는데 제가 가만히 코드를 들여다보니 data 객체는 템플릿이 정해져 있습니다. 게다가 저는 outputs로는 simpleText의 text밖에 쓰지 않고, quickReplies도 상당 부분이 그냥 goMain을 하는 거였습니다. 따라서 템플릿 객체를 반환해주는 함수를 방금 하나 짰습니다.

그 결과 코드의 절반을 타노스했습니다. 거의 200줄 되던 걸 100줄로 줄였습니다. ㅎㅎ

 

타노스했다

 

함수는 다음과 같습니다.

//simpleText:String, reply:Array
function getTemplate(simpleText, reply='') {
  const quickReplies = [];
  
  if (reply) {
    reply.forEach(value => {
      quickReplies.push({
        'label': value,
        'action': 'message',
        'messageText': value
      });
    });
  } else {
    quickReplies.push({
      'label': '처음으로',
      'action': 'message',
      'messageText': '처음으로'
    });
  }

  const data = {
    'version': '2.0',
    'template': {
      'outputs': [{
        'simpleText': {
          'text': simpleText
        }
      }],
      'quickReplies': quickReplies
    }
  }
  
  return data;
}

 

getTemplate이라는 함수는 simpleText:String, reply:Array 매개변수를 받습니다. quickReplies에 goMain만 넣고 따로 건드리지 않아도 되는 상황을 대비해 reply는 기본값을 falsy한 값으로 줬습니다.

만약 reply가 truthy한 값을 갖는다면 그것은 뭔가 의미 있는 값이 매개변수로 넘어왔다는 뜻이므로 forEach를 해서 quickReplies 배열에 push합니다. 아무것도 넘어오지 않았다면 그냥 '처음으로'라는 숏컷 버튼을 띄우도록 하고요.

data는 res.json()으로 넘어갈 카카오톡 응답 메시지의 형식입니다. 여기에 매개변수로 넘어온 simpleText와 quickReplies를 채워서 반환합니다.

 

실제로는 어떻게 쓰게 될까요? 다시 app.post('/message')의 콜백으로 돌아가 보겠습니다.

if (question === '오늘') {
    if (yoil === 'SUN' || yoil === 'SAT') {
      data = getTemplate('오늘은 주말입니다. :)');
    } else {
      data = getTemplate( `${meal[today].day} ${meal[today].student} ${meal[today].staff}`);
    }

  } else if (question === '내일') {
    if (yoil === 'FRI' || yoil === 'SAT') {
      data = getTemplate('내일은 주말입니다. :)');
    } else {
      data = getTemplate(`${meal[today + 1].day} ${meal[today + 1].student} ${meal[today + 1].staff}`);
    }

  } else if (question === selectDay || question === goBack) {
    data = getTemplate('요일을 선택하세요.', ['월', '화', '수', '목', '금']);

  } else if (question === '상시메뉴') {
    data = getTemplate(`${meal[0].meal}`);

  } else if (question === '월' || question === '화' || question === '수' ||
            question === '목' || question === '금') {
    const dayObj = { '월': 1, '화': 2, '수': 3, '목': 4, '금': 5};
    data = getTemplate(`${meal[dayObj[question]].day} ${meal[dayObj[question]].student} ${meal[dayObj[question]].staff}`,
                        [goBack, goMain]);

  } else {
    data = getTemplate('개발 중이거나 오류입니다.\n' +
                        '개발자에게 문의해 주세요. \n' +
                        '이메일 : dev.dain.k@gmail.com');
  } 

 

 

'오늘'이라는 발화가 들어오면 오늘이 주말일 때는 제공할 식단이 없으므로 주말이라는 메시지를 getTemplate에 보내서 반환값을 돌려받아 res.json(data)로 JSON 응답을 보냅니다. '내일'일 때도 마찬가지입니다. 사실 이건 요일을 나타내는 숫자 today로 조건문을 만들어도 되지만 좀 더 직관적으로 표현하고 싶어서 yoil을 사용했습니다.

원래 코드는 여기서 볼 수 있는데 중복을 없애서 코드 양을 확 줄인 모습입니다.

코드는 여기서 끝입니다. 전체 코드는 여기서 확인하실 수 있습니다.

 

 

4. i 오픈빌더 페이지에서 블록 만들기

이제 카카오 i 오픈빌더 페이지에서 블록을 만들고 발화를 입력해주면 됩니다.

제 경우에는 '오늘', '내일', '요일 지정', '월'~'금', '상시메뉴' 등에 따른 블록들을 만들어 주고 사용자 발화를 app.post('/message') 콜백 내 분기문에서 쓴 것과 정확히 똑같이 써 줬습니다. 제가 발화를 여러 개 넣어봤는데 분기문과 조건이 정확히 맞지 않으면 그냥 응답 자체를 안 해버리더군요 ㅠ 해결법을 아시는 분은 댓글로 공유해주시면 감사하겠습니다.

이 때 블록은 다 따로 만드셔야 합니다. 그리고 봇 응답에서 스킬데이터 사용을 눌러 스킬을 사용한다고 설정해 주셔야 합니다.

 

 

그런 다음 배포 탭에서 새로 배포를 해 줍니다.

잠시 기다렸다가 테스트를 해보면...

 

 

잘 동작하는 것을 확인할 수 있습니다.

여기서 끝!이면 좋겠지만 여전히 putty를 내리면 node 프로세스가 종료되면서 동작을 멈추게 됩니다. 다음 포스팅에서는 이를 해결할 수 있는 pm2 사용법을 간단히 다뤄보고 추가로 nginx를 설치해 3000 포트가 아닌 80 포트로 요청을 받을 수 있도록 해 보겠습니다.

감사합니다.

 

 

다음 글 보기

 

Node.js로 카카오톡 학식 채팅봇 만들기 (3)

이전 포스팅 Node.js로 카카오톡 학식 채팅봇 만들기 (2) 이전 포스팅 Node.js로 카카오톡 학식 채팅봇 만들기 (1) 원래 Python3 + Django2 조합으로 카카오톡 챗봇을 만들어 운영하고 있었는데요. (관련 튜

dev-dain.tistory.com

 

 

 

Reference

 

 

728x90
반응형
Comments