Node.js에서 전문 RSS 피드 만들기(feat. Readability)

들어가며: RSS 피드

트위터나 페이스북과 같은 타임라인이 주요한 정보원이 되면서 최근에는 많은 주목을 받지 못 하고 있습니다만, 새로운 정보를 제공하는 유용한 포맷 중 하나로 RSS가 있습니다. 현재는 예전만큼 인기를 얻지는 못 하고 있습니다만, 블로그 시대에 등장한 RSS 포맷은 지금도 많은 블로그와 웹사이트에서 제공하고 있습니다. 사람들이 즐겨듣는 팟캐스트의 기본 포맷은 여전히 RSS 피드입니다. 가끔은 블로그 운영자도 모를 수 있습니다만, 예를 들어 티스토리Tistory 블로그의 루트 URL 뒤에 feed를 붙이면 RSS가 나오며, 유튜브Youtube미디엄Medium에서도 공식적으로 채널 별 RSS를 제공하고 있습니다.

RSS로 정보를 구독하는 건 매우 유용합니다만, 블로그의 경우 작성자가 원치 않을 경우 RSS를 피드를 없애버리거나 요약 피드만 제공하는 게 일반적입니다. 이 글에서는 mozilla/readability 라이브러리를 사용해서 노드jsNode.js로 특정 웹페이지의 전문을 읽기 모드로 스크랩하는 방법과, 요약 RSS를 전문 RSS로 만드는 방법을 소개합니다.*

* 이 글은 개인적인 활용을 위한 가이드입니다. 스크랩한 결과나 직접 생성한 전문 RSS를 작성자의 의도와 관계없이 온라인 상에 공개하는 경우 저작권 침해가 될 수 있으니 주의가 필요합니다.

44BITS 소식과 클라우드 뉴스를 전해드립니다. 지금 5,000명 이상의 구독자와 함께 하고 있습니다 📮

읽기 모드와 mozilla/readability

본격적인 프로그래밍에 앞서서, 먼저 읽기 모드에 대해서 이해할 필요가 있습니다. 제목과 본문 일부만을 보여주는 요약 피드를 가정해보겠습니다. 이 경우 RSS 리더로는 전문을 읽는 게 불가능하고 URL을 열어서 내용을 보아야합니다. 이 페이지에 실린 내용을 스크랩하고 싶지만, 페이지에는 본문 이외에도 다양한 정보가 포함되어있기 때문에 그대로 사용하기는 어렵습니다. 따라서 웹페이지에 포함된 본문만 추출해낼 필요가 있습니다. 하지만 모든 블로그는 독자적인 포맷을 가지고 있고, 이 독자적인 포맷을 하나씩 분석해가며 본문을 찾아낸다는 건 현실적으로 불가능합니다. 이 때 사용할 수 있는 게 바로 읽기 모드입니다.

모던 브라우저에서는 웹페이지를 본문만 추출해서 볼 수 있는 읽기 모드를 지원합니다. 예를 들어 다음 스크린샷은 44BITS의 기사 하나를 파이어폭스에서 열어본 결과입니다.

파이어폭스에서 본 44BITS의 기사

파이어폭스는 문서의 구조를 분석해 본문만 추출해 보여줄 수 있으면 주소 창의 오른 쪽 끝에 문서 아이콘을 표시합니다. 이 아이콘을 클릭하면 페이지가 읽기 모드로 변환됩니다.

파이어폭스에서 읽기 모드로 연 44BITS의 기사

헤더, 푸터, 사이드바와 기타 장식 요소들이 전부 사라지고, 본문만 덩그라니 남아있습니다. 읽기 모드를 사용해 모든 페이지를 같은 경험으로 읽을 수 있습니다. 단, 읽기 모드는 완벽하지 않기 때문에, 읽기 모드로 변환하면 페이지가 깨지거나 일부 정보가 유실되는 경우가 있습니다. 네이버 블로그나 미디엄 같은 블로그 플랫폼에서도 제대로 동작하지 않는 경우가 많습니다. 하지만 텍스트 위주의 구조가 잘 잡힌 페이지는 읽기 모드에서도 거의 완벽하게 동작하는 편입니다.

그럼 이 유용한 읽기 모드로 변환된 본문을 스크랩할 수는 없을까요? 읽기 모드로 페이지를 변환해주는 라이브러리가 바로 mozilla/readability입니다.

readability는 모질라에서 개발중인 노드js 라이브러리로 파이어폭스의 읽기 모드 기능을 독립적으로 제공합니다..

mozilla/readability로 본문 가져오기

먼저 개발환경을 셋업하고, 간단한 샘플코드를 실행해보겠습니다. 작업 환경은 맥OS 카탈리나macOS Catalina 10.15.3, 노드js 12.16.2입니다. readability는 NPM에 퍼블리시 되어있지 않기 때문에 깃허브GitHub에 있는 최신 커밋 버전(52ab9b5c8)을 사용했습니다.

$ node --version
v12.16.2
$ npm i --save 'mozilla/readability#52ab9b5c8'
$ npm i --save jsdom

먼저 간단한 테스트를 위해 wget으로 페이지를 하나 다운로드 받습니다.

# wget이 없다면 먼저 설치합니다.
$ brew install wget

# 페이지를 input.html 파일에 저장합니다.
$ wget -O input.html https://www.44bits.io/ko/post/docker-container-trouble-shooting-by-exec-and-commit 

이제 간단한 예제 코드를 통해서 이 문서에서 본문을 추출해보겠습니다.

const fs = require('fs')
const JSDOM = require('jsdom').JSDOM
const Readability = require('readability')

const body = fs.readFileSync('input.html', 'utf8')
const doc = new JSDOM(body)
const reader = new Readability(doc.window.document)
const article = reader.parse()

fs.writeFileSync('output.html', article.content)

코드 자체는 단순합니다. 먼저 미리 받아둔 파일을 읽고, 이 파일을 JSDOM 객체로 만든다음 다시 readability로 넘겨줍니다. JSDOM 객체로 만드는 이유는 readability의 입력 형식이 JSDOM 문서이기 때문입니다. 마지막으로 readability에서 파싱을 하면 본문을 담은 객체가 만들어집니다. 최종적으로 파싱된 article은 다음과 같은 속성을 가지고 있습니다.

각 정보는 문서의 구조에 따라서 파싱이 될 수도 있고, 정보가 없을 수도 있습니다. 이 중에 가장 중요한 건 titlecontent입니다. content에는 읽기 모드에서 보이는 본문 내용만 HTML로 저장됩니다. 이 내용을 output.html로 저장합니다.

input.htmloutput.html 내용을 비교해보겠습니다.

input.html: 원본 웹페이지 데이터
output.html: 본문 HTML을 추출한 결과

output.html은 본문만 추출된 것을 확인할 수 있습니다. CSS가 없기 때문에 보기가 썩 좋지는 않습니다만, 본문을 추출하는 게 목적이므로 별도의 스타일 개선은 진행하지 않습니다.

전문 RSS 피드 만들기

readability를 간단히 사용해봤으니 이제 본격적으로 요약 RSS를 전문 RSS로 변환하는 작업을 해보겠습니다. 여기서 테스트로 사용해볼 피드는 제가 이전에 운영하던 블로그의 피드입니다. 이 피드는 예전 블로그에서 제공하지만 실제로는 44bits에서 발행된 글들의 요약 정보와 URL을 담고 있습니다. 이 피드를 전문 RSS를 가진 피드로 변환해봅니다.

여기서부터 새로 request-promise, rss-parser, rss 3가지 라이브러리를 사용합니다. 각 라이브러리에서는 한 단계씩 진행하면서 설명하도록 하겠습니다. 먼저 NPM으로 설치해둡니다.

npm i --save request-promise rss-parser rss

기사별로 본문 추출하기

앞선 예제 코드에서는 wget으로 미리 페이지를 다운로드 받아서 본문을 추출했습니다만, 이번에는 URL을 넘겨 받아서 본문을 추출하는 함수를 만들어보겠습니다.

const Readability = require('readability')
const JSDOM = require('jsdom').JSDOM
const rp = require('request-promise')

async function readableDocFormUrl(url) {
  const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:69.0) Gecko/20100101 Firefox/69.0'
  const options = {
    url: url,
    headers: {
      'User-Agent': userAgent
    },
    resolveWithFullResponse: true,
    encoding: null,
    timeout: 5 * 1000
  }
  const response = await rp.get(options)
  const doc = new JSDOM(response.body.toString())
  return new Readability(doc.window.document).parse()
}

이 함수에 url을 넘겨주면 이제 readability로 파싱된 결과를 반환합니다.

RSS 읽어오기

RSS 읽어오기 위해서 rss-parser를 사용해보겠습니다. 이 라이브러리에 대한 보다 자세한 정보는 rbren/rss-parser를 참고해주세요.

const rssParser = require('rss-parser')

const FEED_URL = 'https://blog.nacyot.com/feed'

const parser = new rssParser()
const feed = await parser.parseURL(FEED_URL)

for(const item of feed.items.slice(0, 5)) {
  // console.log(item.link)
  item.readableDoc = await readableDocFormUrl(item.link)
}

// 출력 결과
// -> https://www.44bits.io/ko/post/third-free-sre-book-build-seruce-and-reliable-system
// -> https://www.44bits.io/ko/post/44bits-news-letter-2020-week-14-15
// -> https://www.44bits.io/ko/post/docker-container-trouble-shooting-by-exec-and-commit
// -> https://www.44bits.io/ko/post/amazon-ecr-login-by-awscliv2
// -> https://www.44bits.io/ko/post/algo-vpn-server-on-aws-lightsail-summary

RSS의 주소를 넘겨주면 라이브러리가 알아서 해당 내용을 가져와서 파싱을 수행합니다. 여기서는 앞에 5개 기사의 URL 정보를 출력해보았습니다.

다음으로 이 내용을 함수로 만들어 봅니다. 이번에는 링크를 출력하는 대신 앞서 만든 readableDocFromUrl 함수를 사용해서 각 item에 readability 파싱 결과를 주입해줍니다.

const rssParser = require('rss-parser')

async function readableFeed(feedUrl) {
  const parser = new rssParser()
  const feed = await parser.parseURL(feedUrl)
  feed.feedUrl = feedUrl

  for(const item of feed.items.slice(0, 5)) {
    item.readableDoc = await readableDocFormUrl(item.link)
  }

  return feed
}

자 이제 본문이 추출된 결과가 주입된 피드 객체를 준비되었습니다. 하지만 rss-parser로 파싱된 결과는 다시 RSS로 변환이 되지 않기 때문에 이 결과를 RSS로 만드려면 추가적인 작업이 필요합니다.

RSS 생성하기

여기서는 RSS 생성을 위해 rss 라이브러리를 사용합니다. 이 라이브러리에 대한 자세한 정보는 dylang/node-rss를 참고해주세요.

const RSS = require('rss')

async function generateFullContentFeed(feedUrl) {
  const oldFeed = await readableFeed(feedUrl)
  const newFeed = new RSS({
    title: oldFeed.title,
    description: oldFeed.description,
    feed_url: oldFeed.feedUrl,
    site_url: oldFeed.link,
    pubDate: new Date(),
  })

  for(const item of oldFeed.items.slice(0, 5)) {
    newFeed.item({
      title: item.title,
      description: item.readableDoc.content,
      url: item.link,
      guid: item.guid,
      categories: item.categories,
      author: item.creator,
      date: Date.parse(item.isoDate)
    })
  }

  return newFeed
}

여기서 가장 중요한 부분은 각각 item 객체의 description 속성을 item.readableDoc.content로 바꿔치기 해준다는 점입니다. 이를 통해서 newFeed는 기사의 전문을 포함하게 됩니다.

완성

지금까지의 내용을 정리하면 readableDocFormUrl, readableFeed, generateFullContentFeed 세 개의 함수가 됩니다.

const Readability = require('readability')
const JSDOM = require('jsdom').JSDOM
const rp = require('request-promise')
const rssParser = require('rss-parser')
const RSS = require('rss')

async function readableDocFormUrl(url) {
  const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:69.0) Gecko/20100101 Firefox/69.0'
  const options = {
    url: url,
    headers: {
      'User-Agent': userAgent
    },
    resolveWithFullResponse: true,
    encoding: null,
    timeout: 5 * 1000
  }
  const response = await rp.get(options)
  const doc = new JSDOM(response.body.toString())
  return new Readability(doc.window.document).parse()
}

async function readableFeed(feedUrl) {
  const parser = new rssParser()
  const feed = await parser.parseURL(feedUrl)
  feed.feedUrl = feedUrl

  for(const item of feed.items.slice(0, 5)) {
    item.readableDoc = await readableDocFormUrl(item.link)
  }

  return feed
}

async function generateFullContentFeed(feedUrl) {
  const oldFeed = await readableFeed(feedUrl)
  const newFeed = new RSS({
    title: oldFeed.title,
    description: oldFeed.description,
    feed_url: oldFeed.feedUrl,
    site_url: oldFeed.link,
    pubDate: new Date(),
  })

  for(const item of oldFeed.items.slice(0, 5)) {
    newFeed.item({
      title: item.title,
      description: item.readableDoc.content,
      url: item.link,
      guid: item.guid,
      categories: item.categories,
      author: item.creator,
      date: Date.parse(item.isoDate)
    })
  }

  return newFeed
}

여기까지 만든 함수는 3개지만 마지막에 만든 generateFullContentFeed만 사용하면 전문 피드를 생성할 수 있습니다. 이 함수를 사용해 newFeed.xml 파일을 생성합니다.

const newFeed = await generateFullContentFeed('https://blog.nacyot.com/feed')
fs.writeFileSync('newFeed.xml', newFeed.xml())

RSS는 일반적으로 서버로 전달됩니다. 따라서 테스트를 위해 여기서는 http-server를 사용해 파일 서빙 서버를 실행합니다.

$ npm install http-server -g
$ http-server -p 3000

http://127.0.0.1:3000/newFeed.xml 주소로 접속하면 생성된 전문 피드를 확인할 수 있습니다.

새롭게 만들어진 전문 RSS 피드

검증: Devonthink에서 전문 RSS 피드 읽어오기

이 피드가 정상 동작하는지 RSS 리더를 사용해 확인해보겠습니다.* 여기서는 RSS 기능을 지원하는 데본씽크Devonthink에서 앞서 생성한 피드 파일을 읽어와보겠습니다. 먼저 적당한 위치에 피드 객체를 하나 생성하겠습니다.

* 이 RSS 피드는 인터넷에 업로드 되어있지 않습니다. 로컬 서버로 현재 개발 환경에서만 접속 가능하기 때문에 127.0.0.1로 서버에 접속 가능한 애플리케이션에서만 사용할 수 있습니다.

피드 객체 생성을 선택합니다.

다음으로 피드 이름과 URL을 입력합니다. 여기서 URL은 앞서 실행한 로컬 서버의 주소를 지정합니다.

피드 주소를 입력합니다.

데본씽크Devonthink에 피드 객체를 추가하면 처음에 한 번 피드 내용을 읽어옵니다. 비교를 위해 기존 RSS 피드의 내용도 추가해보았습니다. 먼저 기존 피드 내용입니다.

Nacyot의 프로그래밍 이야기 - 기존 요약 피드

다음으로 이 글에서 함께 만들어보면 새로운 전문 피드입니다. 내용을 확인해보면 44BITS 블로그의 전문이 잘 들어가있는 것을 확인할 수 있습니다.

Nacyot의 프로그래밍 이야기 - 새로 만든 전문 RSS

마치며

여기까지 mozilla/readability를 사용해 요약 RSS 피드를 전문 RSS 피드로 변환해보고, 정상 동작하는지 검증까지 해보았습니다. 코드는 아직 개선의 여지가 많이 있습니다. 예를 들어 지금은 순차적으로 파싱을 진행하는데, 동시에 진행할 수도 있고, 에러 처리도 필요합니다. 실제로 사용하려면 좀 더 개선이 필요하지만 이 글은 전문 RSS를 만들어보는 게 목표이니 여기서 마무리하겠습니다.

참고로 44BITS의 컨텐츠를 예제로 사용했습니다만, 44BITS는 전문 RSS 피드를 제공하니, 이런 번거로운 작업을 하지 않으셔도 됩니다. 😅

CNCF, Fluentd 프로젝트 졸업을 발표

🗞 새소식, 2019-04-25 - CNCF는 지난 4월 11일 Fluentd가 졸업(Graduated) 단계 프로젝트가 되었다고 발표했습니다. 이로써 오픈소스 로깅 프로젝트인 Fluentd는 CNCF 프로젝트 중에 쿠버네티스(Kubernetes), 프로메테우스(Prometheus), 엔보이(Envoy), 코어DNS(CoreDNS), 컨테이너d(containerd)에 이은 여섯 번째 졸업 단계 프로젝트가 되었습니다.

아마존 웹 서비스(AWS, Amazon Web Service) 계정 생성하기

🗒 기사, 2020-01-06 - 아마존 웹 서비스는 컴퓨팅 자원과 다양한 매니지드 서비스를 제공하는 클라우드 서비스입니다. 이 서비스들을 사용하기 위해서는 우선 아마존 웹 서비스 계정을 만들어야합니다. 이 글에서는 아마존 웹 서비스 계정을 만들고, AWS의 프리티어와 계정 생성 이후 추가로 해야할 일들에 대해서 소개합니다.

아마존 EC2(Amazon EC2) 인스턴스 타입 검색 기능 추가

🗞 새소식, 2020-01-10 - 지난 10월 22일 아마존 EC2에는 인스턴스 타입을 검색하고 비교할 수 있는 검색 메뉴가 추가되었습니다. 아마존 EC2는 다양한 인스턴스 타입을 제공해서 선택하는 것도 쉽지 않았습니다. 인스턴스 검색 기능을 사용해 현재 상황에서 적절한 인스턴스 타입을 선택하는데 도움이 될 것으로 보입니다. 이 기능은 웹 콘솔과 API로 제공됩니다.