본문 바로가기

Programming/토이 프로젝트

[severless] 학교 공지사항 크롤러 (Puppeteer + AWS Lambda + DynamoDB => slack)

지난 글에서 작성한 [웨일 확장앱 개발기]도 그렇고 이번에도 학교랑 관련된 개발을 진행해보았다. 일정한 시간 간격으로 크롤링해서 학교 공지사항을 알려주는 봇이다. 크롤링으로 많이 사용하는 언어는 python이라고하는데 puppeteer를 마침 써볼 일이 생겨서 javascript로 개발을 했다.

먼저, 이 크롤링 봇의 결과물은 다음과 같다. 학교 친구들이랑 같이 사용하고있는 텀 프로젝트용 Slack에 webhook을 만들어서 정해진 시간에 새로운 글을 메세지로 보내줄 수 있도록 했다.

결과물

시작하기전에!

1. node.js 버전 12.x

2. AWS 계정 생성 후, AWS Configure 등록하기

- free-tier 범위내에서 충분히 커버가능하다.

3. puppeteer, cheerio에 대한 기본 지식

프로젝트 세팅

1. 패키지 설치

프로젝트 폴더를 하나 만든 후 npm init -y로 package.json 파일을 하나 만들어주자.

필요한 패키지를 다운받자.

 "dependencies": {
   "aws-sdk": "^2.799.0",
   "axios": "^0.21.0",
   "cheerio": "^1.0.0-rc.3",
   "chrome-aws-lambda": "^5.5.0",
   "lodash": "^4.17.20",
   "puppeteer": "^5.5.0",
   "puppeteer-core": "^5.5.0"
 },
aws-sdk aws에 접근할 떄 사용한다.
axios slack webhook에 요청을 보낼 때 사용한다.
cheerio puppeteer로 받아온 페이지를 파싱하여 원하는 부분의 정보만 가져올 때 사용한다.
chrome-aws-lambda lambda에서 chromium을 사용하기위해 사용한다.
lodash 굳이 설치하지않아도 되지만, 배열을 쉽게 다루고 싶어서 사용했다. (아래에서 더 설명하겠다.)
puppeteer, puppeteer-core chrome팀에서 공개한 chrome 브라우저를 제어하는 node.js 라이브러리인데 이걸로 크롤링할 페이지에 접속을 해볼 것이다.

2. serverless.yml 작성

service: school-crawler

provider:
    name: aws
    runtime: nodejs12.x
    memorySize: 512
    timeout: 30
    region: ap-northeast-2
    iamRoleStatements:
        - Effect: "Allow"
          Action:
              - dynamodb:GetItem
              - dynamodb:UpdateItem
              - dynamodb:PutItem
              - dynamodb:Query
              - dynamodb:Scan
          Resource: "*"

package:
    exclude:
        - node_modules/puppeteer/.local-chromium/**

functions:
    snowe:
        handler: app/snowe.handler
        environment:
            ENV_NAME: ${file(./config.js):webConfig.ENV_NAME}
        events:
            - schedule: cron(5 0-8 ? * MON-FRI *)
  • service: serverless 프로젝트의 서비스 이름을 작성하면 된다.
  • provider
    • runtime: lambda가 실행될 환경
    • region: lambda가 실행될 리전
    • iamRoleStatements: lambda가 다른 aws 서비스에 접근할 권한을 명시하면된다. 여기선, dynamodb를 사용했기때문에 Get, Update, Put, Query, Scan에 대한 권한을 추가했다. Resource에 특정 dynamodb의 arn을 명시하는 것도 좋다.
  • package: serverless를 배포할 때 전부 압축하는데, 이때 puppeteer 하위의 .local-chromium을 포함하게되면 zip 파일의 크기가 200MB가 넘어서 제한 용량을 초과했다는 문구와 함께 배포를 할 수 없다. 꼭 제거하자
  • function
    • 하나의 serverless serivce에는 여러 개의 함수를 등록할 수 있다.
    • 함수명은 원하는대로 작성하면 된다.
      • handler: 실행시킬 함수
      • environment: 함수 내에서 사용할 환경변수 정의 (아래에서 더 설명하겠다.)
      • events/schedule: 해당 함수 실행 예약 표현식 정의, 참고로 cron 표현식은 UTC 기준이므로 한국 시간 기준 -9시간 해줘야한다. (Rate, Cron 두 가지 형태가 있다)

크롤링 함수 작성

기본적인 세팅은 완료했으니, 함수를 작성해보자. 위의 serverless.yml에서 app폴더 하위라고 명시를 했으니, app 폴더를 만들고 index.js 파일을 생성해보자.

// app/index.js

const cheerio = require('cheerio');
const chromium = require('chrome-aws-lambda');

module.exports.handler = async (event, context) => {
    try {
        const browser = await chromium.puppeteer.launch({
            args: chromium.args,
            defaultViewport: chromium.defaultViewport,
            executablePath: await chromium.executablePath,
            headless: true,
        });

        let page = await browser.newPage();
        
        // ...

        let pages = await browser.pages();
        await Promise.all(pages.map(page => page.close()));
        await browser.close();

        return {
            statusCode: 200,
            body: JSON.stringify({ message: "Success!" })
        }
    } catch (error) {
        throw new Error(`Failed: ${error}`);
    }
};

기본적인 함수는 다음과 같다. 브라우저를 띄우고 페이지로 이동해서 원하는 정보를 크롤링 한 후, 현재 열려있는 모든 창을 닫고 200 코드와 함께 성공했다는 것을 알리고 끝나게 된다.

headless: false 옵션을 준 채 해당 함수를 실행하면 실제로 chrome창이 뜨면서 내가 명시한대로 움직인다.

 

그렇다면 크롤링 코드를 작성해보자.

먼저, 내가 크롤링하고자하는 웹페이지는 로그인이 필요했다. 아이디와 비밀번호를 코드에 직접 노출하긴 싫었기때문에 환경변수를 사용했다. 그리고 크롤링하고자하는 학교 사이트 url이 매우 길었기 떄문에 직접 코드에 명시하고싶지 않았다.

 

config.js를 만든 후 다음과 같이 사용할 환경변수를 정의하면 된다.

// config.js
module.exports.webConfig = () => ({
    SNOWE_ID: '아이디',
    SNOWE_PASSWORD: '비밀번호',
    SNOWE_URL: '각자 크롤링하고자하는 URL',
    SNOWE_LOGIN_URL: '각자 크롤링하고자하는 URL',
    SNOWE_NOTICE_URL: '각자 크롤링하고자하는 URL',
    SLACK_URL_SNOWE: '슬랙 채널 url',
})

 

// serverless.yml
functions:
    snowe:
        handler: app/snowe.handler
        environment:
            SNOWE_ID: ${file(./config.js):webConfig.SNOWE_ID}
            SNOWE_PASSWORD: ${file(./config.js):webConfig.SNOWE_PASSWORD}
            SNOWE_URL: ${file(./config.js):webConfig.SNOWE_URL}
            SNOWE_LOGIN_URL: ${file(./config.js):webConfig.SNOWE_LOGIN_URL}
            SNOWE_NOTICE_URL: ${file(./config.js):webConfig.SNOWE_NOTICE_URL}
            SLACK_URL_SNOWE: ${file(./config.js):webConfig.SLACK_URL_SNOWE}

정의한 환경변수를 이렇게 사용할 수 있다. serverless.yml에서 각 함수의 환경변수를 어떤 파일에서 가져올 지 정의해주었기 때문이다.

 

// app/index.js

const { SNOWE_ID, SNOWE_PASSWORD, SNOWE_URL, SNOWE_LOGIN_URL, SNOWE_NOTICE_URL } = process.env;

// 위의 과정 중략

// 로그인
await page.goto(SNOWE_LOGIN_URL);
await page.type('#userId', SNOWE_ID, { delay: 100 });
await page.type('#userPassword', SNOWE_PASSWORD, { delay: 100 });
await Promise.all([page.click('#loginButton'), page.waitForNavigation()]);

// 로그인 성공 후, 실제 크롤링할 사이트로 이동
await page.goto(SNOWE_NOTICE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
const content = await page.content();

// cheerio로 해당 페이지를 파싱
const $ = cheerio.load(content);

 

보통 공지사항 크롤링을 하게되면, 테이블 형태나 리스트 형태로 되어있을 것이다. 이번 사이트는 테이블형태였다.

let addedNotice = [];

$('#messageList > tbody > tr').each(async (index, list) => {
	let noticeId = Number($(list).find('.num').text());
    let writer = $(list).find('.writer > a').text();
    let title = $(list).find('.title > a > span').text();
    let noticeLink = SNOWE_URL + $(list).find('.title > a').attr('href');
}

사이트의 각 공지사항은 #messageList class id 하위의 tr 태그에 적혀있었다. tr 태그 하위에 a 태그나 span 태그 등의 내가 원하는 정보만 긁어올 수 있다. 그래서 한 페이지에서 긁어온 글들을 addedNotice라는 배열에 넣어준다.

 

DynamoDB 사용하기

만약 DB에 저장할 계획이 없다면 이 파트는 건너뛰어도 된다.

 

AWS 콘솔에서 DynamoDB를 생성해보자.

테이블 이름은 각자 정해주면 되며 기본 키는 필수, 정렬 키는 option이다. 

 

 

 

크롤링은 해결했는데, 매번 사이트를 크롤링하는데 이 글이 새 글인지 어떻게 확인할 수 있을까? 라는 의문에 빠지게된다. 다행히 나는 글 목록에서 고유한 글 번호를 확인할 수 있었다.

 

먼저 DB를 scan해서 그중 가장 최근에 올라온 글의 글 번호를 반환하는 함수를 만들자. 해당 예제에서는 error handling 코드를 추가하진 않았지만, 꼭 추가하길 바란다.

const AWS = require('aws-sdk');

AWS.config.update({
    region: 'ap-northeast-2',
    endpoint: "http://dynamodb.ap-northeast-2.amazonaws.com"
})

async function getLatestNoticeId() {
    let dbQueryParams = { TableName: "snowe" }

    let latestNoticeId = 0;

    let data = await docClient.scan(dbQueryParams).promise();

    data.Items.map(item => {
        if (item.noticeId > latestNoticeId) {
            latestNoticeId = item.noticeId;
        }
    });

    return latestNoticeId;
}

 

잠깐 위로 올라가서, 사이트의 모든 목록을 순회하며 가져오고자하는 정보를 각 변수에 저장하는 부분을 다시 읽어보자. 현재 글 번호가 DB에 저장된 가장 최신 글보다 크다면 새 글임을 판단하고 배열에 추가하면 된다.

// ... 윗부분 중략

let latestNoticeId = await getLatestNoticeId();

$('#messageList > tbody > tr').each(async (index, list) => {
	// ! 본인이 크롤링하고자하는 사이트에 맞춰서 코드를 수정해야한다
    let noticeId = Number($(list).find('.num').text());
    let writer = $(list).find('.writer > a').text();
    let title = $(list).find('.title > a > span').text();
    let noticeLink = SNOWE_URL + $(list).find('.title > a').attr('href');

    if (noticeId > latestNoticeId) {
        let item = { noticeId, writer, title, noticeLink };
        addedNotice.push(item);
    }
});
    

if (addedNotice.length !== 0) {
  addedNotice.map(async (item) => {
    await putNoticeItems(item);
    await sendNewItemNotification(item);
    addedNoticeId.push(item.noticeId);
  });
} else {
  await sendNoItemNotification();
}

새로 등록된 글은 DynamoDB에 넣고, Slack 알림을 보내면 된다. 새로 등록된 글이 없다면 Slack 알림만 보내면 된다.

async function putNoticeItems(item) {
    const dbParams = {
        TableName: "school",
        Item: {
            id: uuidv4(),
            noticeId: item.noticeId,
            title: item.title,
            writer: item.writer,
            noticeLink: item.noticeLink,
            createdAt: Date.now(),
            ...
        }
    }

    try {
        await docClient.put(dbParams).promise();
    } catch (error) {
        throw new Error('Failed during putting item to db', error);
    }
}

물론 글마다 글 고유번호가 존재하지만, DB의 id를 추가하고싶어서 uuidv4()를 생성해서 추가해주었다. 끝!

Slack Webhook으로 알림 보내기

만약 Slack webhook을 사용할 계획이 없다면 이 파트는 건너뛰어도 된다.

 

마지막으로 해줘야할 것은 Slack Webhook 전송하기! Slack 관리 페이지에서 수신 웹후크를 추가하면, 웹후크 URL을 알려준다. 웹훅을 보낼 채널을 선택하고 이름, 아이콘을 만들어주면 된다.

async function sendNewItemNotification(item) {
	// ! 본인이 보내고자하는 메세지 형식에 맞춰서 수정해야한다.
    const notificationFormat = {
        'username': '스노위 알리미',
        'text': `*${item.title}*`,
        'attachments': [{
            'color': '#5c7cfa',
            'fields': [
                {
                    'title': '작성자',
                    'value': item.writer,
                    'short': true
                },
                ...
            ],
            "actions": [
                {
                    "type": "button",
                    "text": "글 보러가기",
                    "style": "primary",
                    "url": item.noticeLink
                },
            ]
        }]
    };
    messageBody = JSON.stringify(notificationFormat);

    try {
        console.log(`새로 업데이트 된 글이 있습니다. 글 번호: ${item.noticeId}, 슬랙에 알림을 보냅니다.`);
        await axios.post(SLACK_URL_SNOWE, messageBody)
    } catch (e) {
        throw new Error('Failed sending to channel', e);
    }
}

Format에 맞추면 이렇게 정의해둔대로 메세지를 받아볼 수 있다. 자세한 건 Slack Message Format을 확인해보자.

 

로컬에서 테스트해보기

$ serverless invoke local --function snowe

잘 된다.

배포하기

$ serverless deploy

위 화면은 실제 내 프로젝트를 배포했을 때 화면을 들고왔다. 배포 후, aws 콘솔에 접속하면 다음과 같은걸 볼 수 있다.

 

마무리

Lambda + DynamoDB + Puppeteer를 사용하여 학교 공지사항 크롤링을 해보았다. 사실 교내 학생들이 제때 공지사항을 본다거나, 학교에서 만든 어플이 제대로 기능을 했다면 이런걸 만들 일도 없었을텐데.. 어떻게 만들게되었다. 덕분에 puppeteer 패키지를 사용해볼 수 있게 되었다.

 

추가적으로...

DynamoDB를 사용한 이유는 요즘 NoSQL을 사용하고 있고, AWS Credit이 남아서 DyDB를 선택했을 뿐이다. 다른 특별한 이유는 없다. MySQL을 써도 되고, RDS를 써도 된다.

 

매시간마다 크롤링을 하여 새 글인지 확인하는 방법이 여러가지일텐데 더 좋은 방법이 뭐가있을까 생각을 해보지만 딱히 떠오르지 않는다. 만약 글마다 고유번호를 관리자를 제외하고 아무도 모른다면? 실제로 다른 사이트를 크롤링하고있는데 고유번호도 없으며, 제목까지 같은데 다른 글로 분류되는 경우가 있다. lodash의 differenceWith을 사용해서 모든 element를 확인하는 방법을 사용하고있긴한데 더 좋은 방법을 찾아봐야할 것 같다.

 

따라하다가 에러가 나거나 자세한 정보를 알고싶다면 내가 찾아본 글들의 링크를 참고해보면 될 것이다!

AWS Lambda에서 Puppeteer로 크롤링 하기

Puppeteer를 AWS Lambda에서 실행하기

Serverless + AWS Lambda + AWS CloudWatch + Slack 를 활용한 Web crawler 만들기

Puppeteer를 이용한 웹 크롤링 해보기 (예제 1)

serverless - Hello World Node.js Example

반응형