안녕하세요. 저는 현재 기업 관리 통합 솔루션 질링스의 SW 리더로서 시스템 관리 및 백앤드 개발을 담당하고 있습니다. 최근 서비스에서 기업의 뉴스 요약문을 수집해야 하는 이슈가 생겼습니다. 그러면 어떠한 과정을 거쳐 뉴스 요약문을 수집 했는지에 대해 글을 써보도록 하겠습니다.

이 글은 단순히 기능 설명이 아닌 제가 경험한 지식을 녹여내려 쓴 것이므로 내용이 많을 수 있습니다.


목차


배경

질링스는 기업의 정보를 쉽게 관리 할 수 있도록 지원합니다. 그 기능 중에 하나인 기업 뉴스 관리 기능이 있습니다. 질링스는 현재 사용자가 직접 뉴스를 관리 하지 않아도 기업의 뉴스를 주기적으로 수집하는 스케쥴러 서버를 통해 뉴스를 저장하고 있습니다. 이때 기본적으로 저장하는 데이터는 주소, 제목, 언론사, 발행일자 입니다. 따로 기사 요약문을 저장하지는 않았습니다.

그런데 질링스 고객사인 스타트업 엑셀러레이터 엔슬파트너스가 질링스에서 수집한 뉴스를 엔슬파트너스 홈페이지에 보여주고 싶다는 needs 와 추가적으로 기사 요약문도 보여줄 수 있느냐는 얘기를 듣고 이 부분을 기획하고 개발하기로 결정하였습니다.

  • 개발 목적

    사용자가 뉴스 정보 쉽게 관리 할 수 있도록 지원

  • 개발 목표

    국내 몇백개가 되는 언론사의 뉴스 기사 메타 정보 자동 수집

  • 개발 사항

    1. 사용자측 기업 뉴스 관리 기능 강화
      • 기존 방식 : 사용자가 직접 기사의 주소, 제목, 언론사, 발행일자를 기입해야함
      • 새로운 방식 : 기사의 주소 기입시, 제목, 언론사, 요약문 자동 입력됨
    2. 스케쥴러 서버에서 요약문도 같이 수집하도록 개선

웹 페이지 크롤링 방법

웹 페이지를 크롤링하는 방법은 크게 2가지로 나눌 수 있습니다.

  1. Method GET 요청
  2. Headless browser 사용

1. Method GET 요청

Get 요청에도 Case 가 다른 경우가 있습니다. 웹 사이트가 어떤 유형으로 만들어진 것인지에 따라 응답 결과가 다릅니다. 간단히 Static vs Dynamaic 2가지 유형으로 나눌 수 있습니다. 기술적인 이해를 돕고자 2가지 유형의 결과와 함께 설명하겠습니다.

  • 정적(Static) 사이트

    1
    2
    // linux cmd 에서 실행
    $ curl https://devhaks.github.io

    위 요청 결과는 아래 링크의 내용과 같습니다. 이 결과는 웹페이지의 콘텐츠가 미리 생성된 것을 랜더링 해주기 때문입니다.
    https://github.com/devhaks/devhaks.github.io/blob/master/index.html


  • 동적(Dynamic) 사이트

    Demo: http://fiddle.jshell.net/leejognhak/oe0x2r8u/show/#/user/foo

    1
    2
    // linux cmd 에서 실행
    $ curl http://fiddle.jshell.net/leejognhak/oe0x2r8u/show/#/user/foo

    응답 결과

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    <!DOCTYPE HTML>
    <html>
    <head>
    ...(생략)
    </head>
    <body>
    <div id="wrapper">
    <header>
    <h1><a href="//jsfiddle.net/leejognhak/oe0x2r8u/?utm_source=website&amp;utm_medium=embed&amp;utm_campaign=oe0x2r8u" target="_blank">Edit in JSFiddle</a></h1>
    <div id="actions">
    <ul class="normalRes">
    <li class=&quot;active&quot;>
    <a data-trigger-type="result" href="#Result">Result</a>
    </li>
    </ul>
    <div class="hl"></div>
    </div>
    </header>

    <div id="tabs">
    <div class="tCont result active" id="result"></div>
    <script type="text/javascript">
    window.addEventListener('load', function(){
    if (typeof(EmbedManager) === undefined){
    EmbedManager.loadResult();
    }
    }, false);
    </script>
    </div>
    </div>
    </body>
    </html>

    데모용 페이지를 열어보면 각 페이지 별로 Home, Profile, Posts 단어가 존재 합니다. 위의 페이지 소스 결과를 보면 body 태그 안에는 내용이 없습니다. 즉, 동적 사이트는 정적 사이트 처럼 미리 만들어진 콘텐츠를 보여주는 것이 아니라 필요할 때마다 콘텐츠를 body 태그에 뿌려주는 것입니다. 예로 쇼핑몰 사이트에서는 ‘더 보기’ 버튼을 누르면 상품들이 더 나오는 것처럼 말이죠.


  • GET 요청 방법의 한계점

    • 한글 깨짐
      국내 언론사의 뉴스 기사를 예로 들겠습니다.

      1
      2
      // linux cmd 에서 실행
      $ curl https://www.mk.co.kr/news/it/view/2019/03/191946/

      응답 결과

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      <!DOCTYPE html>
      <html lang="ko">
      <head>
      <title>[ī�崺��] AI�� ���躸�� �ô롦�����ɷ� ���� 1����? - ���ϰ���</title>
      <meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
      <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
      <meta property="article:author" content="�ſ���"/>
      <meta name="apple-mobile-web-app-title" content="mk"/>
      <link rel='stylesheet' type='text/css' href='https://common.mk.co.kr/common/css/2015/news_2015.css'>
      <link rel='stylesheet' type='text/css' href='https://common.mk.co.kr/common/css/2019/error.css'>
      <link rel='shortcut icon' href='//img.mk.co.kr/main/2015/mk_new/favicon_new.ico'>
      <link rel='canonical' href='https://www.mk.co.kr/news/it/view/2019/03/191946/'>
      <link rel='amphtml' href='http://m.mk.co.kr/news/amp/headline/2019/191946?PageSpeed=off'>
      <link rel='alternate' media='only screen and ( max-width: 640px)' href='http://m.mk.co.kr/news/it/2019/191946/'>
      <meta name='title' content='[ī�崺��] AI�� ���躸�� �ô롦�����ɷ� ���� 1����? - ���ϰ���'>
      <meta name='description' content='[ī�崺��] AI�� ���躸�� �ô롦�����ɷ� ���� 1����?, �ۼ���-�ſ���, ����-it, ����-LG CNS�� ������ 12�� ��������(KorQuAD��The Korean Question Answering Dataset)������ ���� ù AI(�ΰ�����)�� ������ ������. �����ڵ��� �ڻ� AI ���α׷� �ɷ��� ���������� ������ �� �ֵ��� ��������. AI�� �����ڰ�'>
      <meta name='classification' content='it'>
      <meta property='article:published' content='2019-03-29' />
      </head>
      <body>
      ...(생략)
      </body>
      </html>

      브라우저로 접속하면 한글로된 글이 보이지만, GET 요청 결과는 한글이 깨진 상태입니다.


      1
      <meta http-equiv="Content-Type" content="text/html; charset=euc-kr">

      그 이유는 위 소스코드의 charseteuc-kr 이기 때문입니다. 현대에 만들어진 사이트는 대부분 utf-8이지만 오래된 홈페이지의 경우 euc-kr인 경우가 있습니다.


    • 페이지 리다이렉트

      이 경우는 url 로 접속 했는데, 다른 url 주소로 튕기는 것입니다. 리다이렉트가 발생되는 시점은 해당 사이트가 어떻게 처리 했는가에 따라 다릅니다. 서버에서 처리 했는지 또는 브라우저의 자바스크립트에 의해 처리 되었는지 파악하기에 무리 입니다.

GET 요청 방법으로 웹페이지 크롤링 하려는 경우, 정적 사이트 & 콘텐츠가 깨지지 않는 사이트 & 리다이렉트 되지 않는 페이지를 대상으로 하시는 것을 권장합니다.

2. Headless browser 사용

앞서 GET 요청 방법으로는 기능을 개발하는데 한계점이 있습니다. 그 한계점을 극복하고자 두번째 방법을 채택하였습니다.

  • Headless browser
    우리가 일상적으로 사용하는 IE, 크롬, 사파리 등의 브라우저는 사용하기 쉽게 GUI(Graphical User Interface) 를 제공합니다. 그러면 Headless 란 의미는 단순히 GUI 를 지원하지 않는다는 것입니다. GUI 를 제공하지 않으면 어떻게 조작을 할 수 있지 라는 의문은 프로그래밍을 통해서 가능합니다. 사용자가 브라우저를 열고 마우스 클릭이나 타이핑 등의 행동을 똑같이 프로그래밍으로 자동화 할 수 있습니다.


  • Puppeteer

    Puppeteer 는 구글 Chrome 또는 Chromium 브라우저를 조작할 수 있도록 도와주는 nodejs 의 패키지 라이브러리 입니다.

    Puppeteer 로 할 수 있는 기능은 다음과 같습니다.

    • SPA (단일 페이지 응용 프로그램)를 크롤링하고 사전 렌더링 된 콘텐츠 (즉, “SSR”(서버 측 렌더링))를 생성합니다.
    • 페이지의 스크린 샷 및 PDF 생성
    • 양식 제출, UI 테스트, 키보드 입력 등을 자동화 합니다.

    이외 더 많은 기능은 Puppeteer API 문서를 참고하시기 바랍니다.


  • 설치

    1
    $ npm i cheerio puppeteer

    cheerio 는 텍스트로된 html 을 DOM으로 파싱해주는 라이브러리 입니다. 파싱 후에는 jQuery 처럼 사용 할 수 있습니다.

    puppeteer 를 설치하려면 각 OS별로 의존성 패키지를 설치 해야합니다.
    개발용으로 사용하는 PC에는 이미 패키지가 설치되어 있을 수 있기 때문에 puppeteer 설치 오류가 발생하지 않을 수 있습니다.
    만약 오류가 발생할 경우, 아래 경로로 찾아갑니다. (디렉토리명이 다를 수 있습니다.)

    1
    2
    3
    4
    5
    6
    7
    8
    $ cd project/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux

    $ ldd chrome | grep not # 설치되지 않은 패키지 목록을 출력합니다.
    #Output not install package list
    ...

    # ubuntu 패키지 설치 예시
    $ sudo apt-get install libatspi-dev libatspi2.0-0 libgtk-3-0

    Puppeteer를 설치하면 API가 작동하는 Chromium의 최신 버전 (~ 170MB Mac, ~ 282MB Linux, ~ 280MB Win)이 다운로드됩니다.

    Puppeteer는 노드 v6.4.0 이상을 필요로하지만 설명할 코드의 예제는 노드 v7.6.0 이상에서만 지원되는 async / await를 사용합니다.


시나리오 및 코드

기사의 메타 정보를 사람이 수집하는 것처럼 puppeteer 를 통해 똑같이 수집 하도록 순서를 나열 해보겠습니다.

  1. 브라우저를 띄운다.
  2. 페이지를 띄운다. (브라우저와 동시에 같이 띄워짐)
  3. 페이지에 뉴스 기사 주소를 입력해서 접속한다.
  4. 웹페에지의 페이지 소스를 확인한다.
  5. 페이지 소스에서 아래 속성 값을 가진 <meta> 태그의 content 속성 값을 추출한다.
    • 제목: title, og:title, twitter:title
    • 요약문: description, og:description, twitter:description
    • 썸네일: og:image, twitter:image
  6. 페이지 닫기
  7. 브라우저 닫기

아래 내용은 질링스 서비스에서 가져온 뉴스 자동 수집 코드의 일부분 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
const puppeteer = require('puppeteer');
const cheerio = require('cheerio');

(async () => {
/**
* @description 기사의 메타 정보를 추출합니다.
*
* @summary 제목, 요약문, 썸네일 정보를 추출합니다.
*
* @params {string} html - 크롤링한 페이지 html
*
* @returns {object}
*/
function extractNewsData(html) {
const newsData = {
title: null,
description: null,
image: null
};

// cheerio 라이브러리를 사용하여 html을 DOM 으로 파싱합니다.
const $ = cheerio.load(html);
// meta 태그만 추출합니다
const $metaList = $('meta');

for (let index = 0; index < $metaList.length; index += 1) {
const element = cheerio($metaList[index]);

// meta 태그의 content 속성 값을 추출합니다.
let content = element.attr('content');

if (!content || !content.trim()) {
continue;
}
content = content.trim();

// meta 태그의 property 속성 값을 추출합니다.
let propertyAttr = element.attr('property');
if (propertyAttr) {
propertyAttr = propertyAttr.toLocaleLowerCase();
}

// 추출할 property 에 따라 newsData 에 할당합니다.
switch (propertyAttr) {
case 'og:title':
newsData.title = content;
break;
case 'og:description':
newsData.description = content;
break;
case 'og:image':
newsData.image = content;
break;
default:
break;
}

// meta 태그의 name 속성 값을 추출합니다.
let nameAttr = element.attr('name');
if (nameAttr) {
nameAttr = nameAttr.toLocaleLowerCase();
}

// 추출할 name 에 따라 newsData 에 할당합니다.
switch (nameAttr) {
case 'title':
case 'twitter:title':
newsData.title = content;
break;
case 'description':
case 'twitter:description':
newsData.description = content;
break;
case 'twitter:image':
newsData.image = content;
break;
default:
break;
}
} // end for

return newsData;
} // end extractNewsDate()

// 브라우저 옵션 설정
const browserOption = {
slowMo: 500, // 디버깅용으로 퍼핏티어 작업을 지정된 시간(ms)만큼 늦춥니다.
headless: false // 디버깅용으로 false 지정하면 브라우저가 자동으로 열린다.
};

// 1. 브라우저를 띄운다. => 브라우저 객체 생성
const browser = await puppeteer.launch(browserOption);

// 2. 페이지를 띄운다. => 페이지 객체 생성
const page = await browser.newPage();

let response;
try {
// 리다이렉트 되는 페이지의 주소를 사용.
const url =
'http://www.thebell.co.kr/front/free/contents/news/article_view.asp?key=201807250100046030002891';

// 탭 옵션
const pageOption = {
// waitUntil: 적어도 500ms 동안 두 개 이상의 네트워크 연결이 없으면 탐색이 완료된 것으로 간주합니다.
waitUntil: 'networkidle2',
// timeout: 20초 안에 새 탭의 주소로 이동하지 않으면 에러 발생
timeout: 20000
};

// 3. 새 탭에 뉴스 기사 주소를 입력해서 접속한다.
response = await page.goto(url, pageOption);
} catch (error) {
await page.close();
await browser.close();

console.error(error);
return;
}

let html;
try {
// 4. 웹페에지의 페이지 소스를 확인한다. => 페이지 소스 코드를 얻는다.
html = await response.text();
} catch (error) {
console.error(error);
return;
} finally {
// catch 구문에 return 이 존재해도 finally 구문은 실행 합니다.

// 6. 페이지 닫기
await page.close();
// 7. 브라우저 닫기
await browser.close();
}

// 5. 페이지 소스에서 아래 속성 값을 가진 `<meta>` 태그의 `content` 속성 값을 추출한다.
const newsData = extractNewsData(html);

// 크롤링 결과
console.log(newsData);
})();
  • 시나리오 순서와 달리 코드에서 5번이 가장 나중에 온 이유는 html 변수에 이미 데이터를 할당했기 때문에 먼저 브라우저를 종료해도 문제가 발생하지 않습니다.

  • page.goto(url, pageOption) 함수에 리다이렉트 되는 url로 예시를 든 이유는 GET 요청 방법의 한계점을 해결 할 수 있기 때문입니다.

  • Browser option 브라우저를 띄울 때, 설정 할 수 있는 옵션 정보입니다.

  • Page option 페이지를 띄울 때, 설정 할 수 있는 옵션 정보입니다.


Tips

  • Page Event - 불필요한 리소스 막기

    어떤 리소스를 허용 또는 막을지를 설정하여 페이지 로드 시간을 단축 할 수 있습니다.
    뉴스를 크롤링 하는데 image, stylesheet, font같은 용량이 많은 리소스는 다운로드 시간이 오래 걸리기 때문에 시간 단축에 효과적입니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    const page = await browser.newPage();

    const blockResource = [
    'image',
    'script',
    'stylesheet',
    'xhr',
    'font',
    'other'
    ];

    await page.setRequestInterception(true);

    page.on('request', req => {
    // 리소스 유형
    const resource = req.resourceType();
    if (blockResource.indexOf(resource) !== -1) {
    req.abort(); // 리소스 막기
    } else {
    req.continue(); // 리소스 허용하기
    }
    });

  • Page Event - Window.alert 강제 끄기

    해당 페이지에 dialog 가 발생하면 해당 페이지만 정지 상태가 됩니다. 여러 페이지를 크롤링하는 상황에서 정지 상태로 내버려 두면, 페이지를 종료 할 수 없기 때문에 메모리 누적 문제가 발생합니다.
    dialog 를 강제로 끄면 페이지 옵션 timeout 시간 초과에 의해 페이지가 종료 됩니다.

    1
    2
    3
    4
    5
    6
    const page = await browser.newPage();

    page.on('dialog', async dialog => {
    console.log(`dialog message:' ${dialog.message()}`);
    await dialog.dismiss();
    });

  • 성능 이슈

    • CPU, Memory 사용량

      Headless browser 를 사용함으로써 개발은 확실히 편하지만, 컴퓨팅 자원을 어느정도 사용하기 때문에 제한된 컴퓨팅 자원으로 어떻게 효율적으로 사용할 지를 고민하셔야 합니다.

    • 크롤링 속도

      브라우저 객체를 생성하고 페이지를 객체를 생성하는데 아무리 컴퓨팅 성능이 좋다 하더라도 시간이 꽤 걸립니다.


마치며…

뉴스 메타 정보 수집 기능을 구현하면서 다양한 CASE 를 마주하였고 어떻게 풀어 나갈지에 대해 고민한 부분을 정리하였습니다. 이 글을 보신 분은 도움이 되셨으면 합니다.

피드백은 언제나 환영합니다.