개발자

R을 이용해 나만의 RSS 리더 만들기 8단계

Sharon Machlis | InfoWorld 2023.01.04
RSS 피드는 90년대 후반 등장한 이후 지금까지 여러 뉴스 소스로부터 최신 소식을 받아보기 위한 요긴한 방법으로 사용되고 있다. 피드를 잘 선택하고 RSS 리더를 통해 여러 소스로부터 헤드라인을 훑어보면 변화가 빠른 주제에 관한 최근 동향을 손쉽게 파악할 수 있다. 시중에서 구할 수 있는 상용 또는 오픈소스 RSS 리더도 좋지만 직접 만들면 훨씬 더 만족도가 높다. 

R을 사용해 나만의 RSS 피드 리더를 만들기는 생각 외로 쉽다. 여기서 소개하는 8단계를 따르기만 하면 된다. 
 

1단계 : 콰르토 문서 또는 R 스크립트 파일 만들기 

일반 R 스크립트를 사용해도 되지만 콰르토(Quarto)를 통해 유용한 스타일을 추가할 수 있다. 또한 최종 표시에 자바스크립트를 사용하려는 경우에도 콰르토를 통하면 더 쉽게 가능하다. 다만 R 스크립트와 달리 여기서 살펴보는 콰르토 문서는 처음 시작하려면 YAML 헤더가 필요하다. YAML에 몇 가지 설정을 추가해 단일 HTML 파일을 생성하고(embed-resources: true), 코드 또는 코드 메시지나 경고를 표시하지 않도록 한다(echo: false). 
 
---
title: "Sharon's RSS Feed"
format: 
  html
embed-resources: true
editor: source
execute: 
  echo: false
  warning: false
  message: false
---
 

2단계 : 필요한 패키지 불러오기

그다음 R 코드 블록 내에 R 코드를 추가하고(```{r}과```은 콰르토에서 실행 가능한 코드 블록을 둘러싼다. 일반 R 스크립트를 사용하는 경우 필요 없음) 필요한 패키지를 로드한다. 이름에서 짐작하겠지만 tidyRSS는 RSS 피드를 R로 읽어 들이기 위한 라이브러리다. 
 
``{r}
library(tidyRSS)
library(dplyr)
library(DT)
library(purrr)
library(stringr)
library(lubridate)
```
 

3단계 : RSS 피드 추가

적절한 피드 선택은 RSS 리더 경험에서 핵심적인 요소다. 필자는 좋아하는 소스를 바탕으로 찾은 다음 웹사이트를 확인하거나 검색해 RSS 피드가 있는지를 파악한다. 참고로 rvest 패키지를 사용해 사이트맵을 읽고 이를 RSS 형식으로 엮을 수 있지만 여기서 다룰 내용은 아니다.

피드를 별도의 CSV 또는 엑셀 파일에 저장하고 앱에서 그 파일을 가져오는 방법이 좋다. 이렇게 하면 피드 목록을 업데이트할 때 매번 코드를 수정할 필요가 없다. 이 글에서는 간단한 데모를 위해 필자가 원하는 피드와 각각의 제목이 포함된 데이터 프레임을 스크립트 파일에 만든다. 

필자는 인포월드와 컴퓨터월드, 양쪽에 모두 글을 쓰므로 두 피드를 모두 추가한다. 또한 R-블로거(R-Bloggers), R 위클리(R Weekly), 그리고 필자가 사용하는 마스토돈(Mastodon) 인스턴스인 fosstodon.org의 #rstats 및 #QuartoPub RSS 피드를 포함해서 몇 개의 R 관련 RSS 피드도 가져온다. 
 
```{r}
myfeeds <- data.frame(feed_title = c("All InfoWorld", 
                                  "All Computerworld", 
                                  "Mastodon rstats", 
                                  "Mastodon QuartoPub",
                                  "R Bloggers",
                                  "R Weekly"),
                     feed_url = c("https://www.infoworld.com/index.rss",
                                  "https://www.computerworld.com/index.rss",
                                  "http://fosstodon.org/tags/rstats.rss",
                                  "http://fosstodon.org/tags/QuartoPub.rss",
                      "https://feeds.feedburner.com/Rbloggers",
                      "https://rweekly.org/atom.xml")
           ) |>
  arrange(feed_title)
```

참고로 지금부터 보여주는 예시에는 R 코드를 둘러싸는 ```{r} ``` 콰르토 코드 “펜스”를 포함하지 않겠지만, 모든 R 코드는 콰르토 문서에서 이런 '펜스' 처리해야 한다. 
 

4단계 : 모든 피드를 같은 형식으로 

이 과정에서 수작업이 가장 많은 부분이다. 이상적인 경우라면 모든 피드가 정확히 동일한 구조로, 필자가 원하는 형식으로 되어 있고 누락된 데이터도 없을 것이다. 그러나 현실에서는 RSS 데이터 역시 여느 데이터 집합과 마찬가지로 난잡할 수 있다. 따라서 피드를 확인해서 데이터를 정리할 필요가 있는지, 있다면 어떤 방법으로 해야 하는지를 살펴봐야 한다. 또한 RSS 피드와 함께 R-블로거와 같은 아톰 피드를 가져올 수 있도록 할 것이므로 이 부분도 고려해야 한다. 

간소함을 위해 리더는 제목, 업데이트된 항목 날짜/시간, 항목 설명, 그리고 원본(URL) 항목을 클릭하는 방법만 표시한다. 먼저 tidyRSS를 사용해서 각 피드를 목록으로(각 피드당 하나의 목록 항목) R로 가져온 다음 각각을 검사해서 발생 가능한 문제가 있는지 확인한다. 
 
feed_test <- map(myfeeds$feed_url, tidyfeed)

이 코드는 개발 용도이며 최종 RSS 리더 파일에는 포함되지 않는다. 
 

5단계 : 피드 랭글링 함수 만들기 

랭글링 함수는 다음과 같이 간단하게 시작한다. 
 
wrangle_feed <- function(the_feed_url, the_feed_dataframe = myfeeds) {
  my_feed_data <- tidyRSS::tidyfeed(the_feed_url)
  return(my_feed_data)
}

피드 제목의 경우 피드 작성자가 정한 제목이 아닌 필자의 스프레드시트에 있는 제목으로 설정하려고 한다. 따라서 피드 데이터 프레임을 사용해 제목을 조회하고 기존 피드 제목을 다음 코드로 대체한다. 
 
my_feed_data$feed_title <- the_feed_dataframe$feed_title[the_feed_dataframe$feed_url == the_feed_url][1]

item_title, item_date, item_description, item_link를 선택한다. 아톰 피드인 경우에는 entry_title, entry_last_updated, entry_content, entry_url이 된다. 원하는 열을 선택하기 전에 아톰 피드인지를 확인하고 아톰 피드인 경우 아톰 열 이름을 다음으로 바꾼다. 
 
  if("entry_url" %in% names(my_feed_data)) {
    my_feed_data <- my_feed_data |>
      rename(item_title = entry_title, item_pub_date = entry_last_updated, item_link = entry_url, item_description = entry_content) 
  }

마스토돈 RSS 피드에는 포스트에 대한 제목이 없다. 각 포스트에 동일한 기본 제목을 추가할 수 있지만(예를 들어 “마스토돈 포스트”) 필자는 “{username}의 마스토돈 포스트”와 같은 제목을 선호한다. 대부분의 마스토돈 포스트 URL에는 @로 시작하는 작성자 핸들이 포함되지만 가끔 그렇지 않은 경우도 있다. 다음 코드로 마스토돈 URL에서 사용자 이름을 추출하고 맞춤형 제목을 추가할 수 있다. 링크에 명확한 작성자가 없는 경우 기본 제목은 “Mastodon Post”로 설정된다. 
 
if(str_detect(my_feed_data$feed_title[1], "Mastodon")) {
  my_feed_data <- my_feed_data |>
    mutate(
      item_author = str_replace_all(item_link, "^.*?\\/(\\@.*?)\\/.*?$", "\\1"),
      item_title = if_else(str_detect(item_author, "@"), paste0("Mastodon Post by ", item_author), "Mastodon Post")
    )
  }

피드 제목에 “Mastodon”을 넣었기 때문에 모든 마스토돈 피드를 손쉽게 찾을 수 있다. str_replace_all() 코드는 정규식을 사용해 URL에서 작성자를 찾는다. "^.*?\\/(\\@.*?)\\/.*?$" 패턴은 문자열 시작부터 @ 앞의 /까지 모든 내용을 삭제하고, @부터 다음 / 바로 앞까지의 모든 내용을 유지한 다음 그 외에는 모두 삭제한다. 

다음으로, 원하는 열을 선택해서 이름을 바꾸고 각 항목을 클릭해 원본 소스로 이동할 수 있도록 하는 등의 부가적인 데이터 랭글링 작업을 한다. 다음 코드는 열을 선택해 이름을 바꾸고 클릭 가능한 헤드라인 열을 생성한다. 
 
 my_feed_data <- my_feed_data |>
  select(Headline = item_title, Date = item_pub_date, URL = item_link, 
         Description = item_description, Feed = feed_title) |>
  mutate(
    Headline = str_glue("<a target = '_blank' title = '{Headline}' href='{URL}'>{Headline}</a>")
)

많은 사람이 클릭 가능한 헤드라인을 좋아한다. 그러나 필자는 클릭 가능한 헤드라인보다는 설명의 끝에 달린 클릭 가능한 >> 표시를 더 선호한다. 다음 코드는 이를 구현한 것이다.
 
my_feed_data <- my_feed_data |>
  select(Headline = item_title, Date = item_pub_date, URL = item_link, Description = item_description, Feed = feed_title) |>
  mutate(
    Description = str_glue("{Description}, <strong> <a target = '_blank' href = '{URL}'> >></a></strong>"),
  ) 
 

6단계 : 선택적인 몇 가지 데이터 조정 추가 

지금까지의 코드는 기본적인 피드 리더를 위한 데이터를 생성하기에는 충분하지만 몇 가지 조정을 통해 앱을 더 보기 좋게 할 수 있다. 예를 들어 R 블로거 아톰 피드에는 전체 블로그 내용이 포함되는데 필자는 RSS 리더로 전체 내용을 다운로드하고 싶지 않다. 빠르게 스캔하기가 더 어려워지기 때문이다. 다른 설명 부분도 필자가 원하는 것보다 더 길 수 있다. 

다음은 max_chars 문자 수를 초과하는 부분부터 설명을 잘라내는 함수다. 단, 단어 중간이 잘리지 않도록 가장 가까운 완전한 단어에서 잘라낸다. 그런 다음 말줄임표를 추가한다. 설명이 없을 때 코드가 헤매지 않도록 이 함수는 먼저 설명이 있는지를 확인한다. 
 
trim_if_too_long <- function(item_description, max_chars = 600) {
  if(!is.na(item_description)) {
    if(nchar(item_description) > max_chars) {
      item_description <-  stringr::str_sub(item_description, 1, max_chars)
      item_description <-  str_replace_all(item_description, "\\s[^\\s]+$", ". . . ")
    }
    return(item_description)
  } else {
      return("")
    }
}

이 함수는 항목 설명이 max_chars(현재 기본값 600)보다 긴 경우에만 변경을 수행한다. 설명이 실제로 그보다 길면 코드의 첫 번째 라인이 max_chars 길이로 텍스트를 자른다. 두 번째 라인은 정규식을 사용해서 텍스트 문자열의 끝에 공백이 아닌 하나 이상의 문자가 뒤따르는 공백을 말줄임표로 대체한다. 즉, 이 정규식은 설명의 끝에 있는 완전하지 않은 단어를 제거한 다음 말줄임표를 추가한다. 

RSS 리더에서 이 함수를 사용하려면 콰르토 문서 또는 R 스크립트에서 wrangle_feed 함수 정의의 위에 이 함수를 배치해야 한다. 각 피드의 설명에 함수를 적용하기 위해 purrr의 map_chr() 함수를 사용한다.
 
map_chr(Description, trim_if_too_long)

그리고 클릭 가능한 >> 화살표를 추가하기 전에 피드 랭글링에 이것을 추가한다. 
 
 my_feed_data <- my_feed_data |>
  select(Headline = item_title, Date = item_pub_date, URL = item_link, Description = item_description, Feed = feed_title) |>
  mutate(
      Description = purrr::map_chr(Description, trim_if_too_long),
      Description = str_glue("{Description}, <strong> <a target = '_blank' href = '{URL}'> >></a></strong>"),
  )  

필자가 선택한 피드 중 일부에는 끝에 “전체 기사를 읽으려면 여기를 클릭하세요"라는 텍스트가 포함되지만 정작 클릭은 할 수 없다. str_replace_all()을 사용하면 손쉽게 이와 같은 텍스트를 제거할 수 있다. 앱에서 이 코드를 사용하려면 다른 설명 랭글링을 하기 전에 추가해야 한다. 
 
 my_feed_data <- my_feed_data |>
  select(Headline = item_title, Date = item_pub_date, URL = item_link, Description = item_description, Feed = feed_title) |>
  mutate(
    Description = str_remove_all(Description, "To read this article in full, please click here"),
    Description = purrr::map_chr(Description, trim_if_too_long),
    Description = str_glue("{Description}, <strong> <a target = '_blank' href = '{URL}'> >></a></strong>")
  )  

참고로 날짜/시간이 2022-11-16T08:00:00Z와 같이 표시되는 것이 마음에 들지 않는다면, 루브리데이트(lubridate) 패키지의 format_ISO8601() 함수를 사용하면 원하는 정밀도를 쉽게 설정할 수 있다(필자의 경우 ymdhm을 원하지만 초는 제외). 그다음 “T”를 공백으로 대체해서 날짜 열이 2022-12-21 18:44와 같은 형식으로 표시되도록 할 수 있다.
 
Date = format_ISO8601(Date, precision = "ymdhm"),
Date = str_replace_all(Date, "T", " ")

다음은 필자의 전체 wrangle_feed() 함수다(그 위의 별도의 trim_if_too_long() 함수는 표시되지 않음). 
 
wrangle_feed <- function(the_feed_url, the_feed_dataframe = myfeeds) {
  my_feed_data <- tidyfeed(the_feed_url)
  my_feed_data$feed_title <- the_feed_dataframe$feed_title[the_feed_dataframe$feed_url == the_feed_url][1]
 if("entry_url" %in% names(my_feed_data)) {
    my_feed_data <- my_feed_data |>
      rename(item_title = entry_title, item_pub_date = entry_last_updated, item_link = entry_url, item_description = entry_content) 
 }
 if(str_detect(my_feed_data$feed_title[1], "Mastodon")) {
    my_feed_data <- my_feed_data |>
      mutate(
        item_author = str_replace_all(item_link, "^.*?\\/(\\@.*?)\\/.*?$", "\\1"),
        item_title = if_else(str_detect(item_author, "@"), paste0("Mastodon Post by ", item_author), "Mastodon Post")
      )
 }  
 my_feed_data <- my_feed_data |>
  select(Headline = item_title, Date = item_pub_date, URL = item_link, Description = item_description,  Feed = feed_title) |>
  mutate(
    Description = str_remove_all(Description, "To read this article in full, please click here"),
    Description = purrr::map_chr(Description, trim_if_too_long),
    Description = str_glue("{Description}, <strong> <a target = '_blank' href = '{URL}'> >></a></strong>"),
    Date = format_ISO8601(Date, precision = "ymdhm"),
    Date = str_replace_all(Date, "T", " ")
    )    
return(my_feed_data)  
}
 

7단계 : 누락되거나 손상된 피드 처리 

피드 중 하나를 사용할 수 없을 때 그 하나의 오류로 인해 코드가 터지거나 멈추지 않도록 해야 한다. purrr의 possibly()를 사용해서 이 함수의 “안전한” 오류 처리 버전을 만들면 된다. 
 
wrangle_feed_safely <- possibly(wrangle_feed, otherwise = NULL)

이 함수의 wrangle_feed_safely() 버전은 오류가 발생하는 경우 멈추는 대신 NULL을 반환한다. 이제 모든 피드 URL에서 함수를 실행하고 purrr의 map_df()를 사용해 하나의 데이터 프레임이 반환되도록 할 수 있다. 또한 다음 코드는 결과를 날짜 내림차순으로 배열해서 소스에 관계없이 최신 항목이 먼저 나오도록 한다.
 
mydata <- map_df(myfeeds$feed_url, wrangle_feed_safely) |>
  arrange(desc(Date))
 

8단계 : 결과 표시 

데이터를 얻기 위한 어려운 부분은 끝났다. 이제 결과를 표시할 차례다. 필자는 결과에 URL 필드를 표시하지 않을 것이므로(설명에 클릭 가능한 >> 표시가 있으니까) URL 필드 없이 표시 테이블에 사용하기 위한 데이터의 복사본을 만든다. 큰 데이터 집합이라면 복사본을 만들지 않겠지만 이 데이터는 작고, 나중에 URL 필드가 필요할 때를 대비한 일종의 백업 성격도 있다. 
 
mytabledata <- select(mydata, -URL)

이 데이터를 표시하는 쉬운 방법의 하나는 테이블이다. 여기서는 정규식 검색을 사용할 수 있는 DT 패키지를 사용한다. 정규식 검색은 “R”과 같은 것을 검색할 때 특히 유용하다. 정규식을 사용하면 R과 같은 패턴을 단순히 대문자로 된 단어의 맨 앞에 있는 R이 아닌 별도의 단어로 검색할 수 있기 때문이다. 

다음 코드에서 볼 수 있듯이 필자의 콰르토 문서에서 테이블 코드 부분을 “column-page” CSS 스타일 클래스로 감싼다(위에는 :::{.column-page}, 아래에는 :::). 이는 테이블을 평상시보다 더 넓게(전체 페이지 너비) 표시하도록 콰르토 문서에 지시한다. column-page는 내장된 CSS 스타일로, 콘텐츠의 너비를 늘린다. 이 수정을 위해 HTML과 CSS를 코딩하는 방법을 알 필요는 없다. 

이 방법으로도 너비가 충분하지 않다면(예를 들어 라인 끊기가 되지 않는 엄청나게 긴 URL로 인해 테이블이 여전히 스크롤될 수 있음) {.column-page} 대신 {.column-screen}을 사용해 페이지 여백을 아예 제거할 수 있다. 

다음 코드는 기본 DT 데이터 테이블도 약간 조정한다. filter = 'top'은 각 열 위에 검색 필터를 추가한다. escape = FALSE는 HTML을 기반 코드로 표시하지 않고 HTML로 표시한다. 검색 옵션에 regex=TRUE와 caseInsensitive=TRUE, 대소문자 무시 검색을 추가한다. 또한 페이지 길이와 페이지 길이 메뉴 옵션을 조정하고, 세 번째 열(Description)을 테이블 너비의 80%로 설정한다. (세 번째 열을 원하는데 대상 열이 2인 이유가 궁금할 수 있는데, 이는 DT가 자바스크립트 라이브러리를 위한 래퍼이고, 기반 라이브러리가 수를 0부터 세는 JS 규약을 사용하기 때문이다.) 
 
:::{.column-page}

```{r}
DT::datatable(mytabledata, filter = 'top', escape = FALSE, rownames = FALSE,
  options = list(
  search = list(regex = TRUE, caseInsensitive = TRUE),  
  pageLength = 25,
  lengthMenu = c(25, 50, 100, 200),
  autowidth = TRUE,
  columnDefs = list(list(width = '80%', targets = list(2)))
  )
)
```

:::
 
RSS 피드 리더 테이블 예제 ⓒ Sharon Machlis

정규식 검색 덕분에 \bR\b 정규식을 사용해서 R을 별도의 단어로 검색할 수 있다. \b는 공백, 구두점 또는 라인의 시작 또는 끝과 같은 “단어 경계”를 나타낸다. 이렇게 해서 간단한 RSS 리더가 완성됐다! 결과를 캐싱하고 표시를 세밀하게 조정하는 등 더 많은 부분을 수정할 수 있다. 예를 들면 다음과 같다. 
 
Available feeds: `r knitr::combine_words(sort(unique(mydata$Feed)))`

RSS 피드를 파싱한 후 이것을 콰르토 문서에 추가하면 사용 가능한 모든 피드 목록이 표시된다.
editor@itworld.co.kr
 Tags RSS 리더 R

회사명 : 한국IDG | 제호: ITWorld | 주소 : 서울시 중구 세종대로 23, 4층 우)04512
| 등록번호 : 서울 아00743 등록발행일자 : 2009년 01월 19일

발행인 : 박형미 | 편집인 : 박재곤 | 청소년보호책임자 : 한정규
| 사업자 등록번호 : 214-87-22467 Tel : 02-558-6950

Copyright © 2024 International Data Group. All rights reserved.