슬로우 웹을 위한 리더 모드 프록시
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

요약: 어떤 기사든 읽기 편하고 인쇄하기 좋은 버전으로 만들어 주는 서비스를 만들었습니다. Firefox/Safari의 리더 뷰와 비슷하지만, 오래된 브라우저에서도 동작하고 공유할 수 있으며 아름다운 타이포그래피에 중점을 두었습니다. 소스 코드를 확인해 보세요.
웹은 한때 정말 재미있는 공간이었다.
요즘은? 별로다. 트래커, 광고, 비대해진 페이지, 전체 화면 팝업, 자동 재생 동영상… 모든 게 너무 지친다.
그저 좋은 차 한 잔과 창턱에서 잠든 고양이, 창밖으로 가볍게 내리는 눈을 벗 삼아 방해받지 않고 긴 글을 읽고 싶을 뿐이다.

슬로우 웹
나는 슬로우 웹 운동과 한 가지 일을 제대로 하는 작은 사이트를 아주 좋아한다.
긴 글을 깔끔하게 읽기 위해 나는 Firefox의 리더 뷰를 사용하는데, 항상 동작하는 것도 아니고 가장 예쁜 것도 아니지만 마음에 든다.
다른 브라우저에도 리더 모드가 있지만, Chrome처럼 실험용 플래그 뒤에 숨겨져 있는 경우도 있다. 내 전자책 리더의 브라우저처럼 아예 리더 모드를 제공하지 않는 경우도 있는데, 그러다 보니 주로 독서용으로 쓰는 기기에서 느리고 만족스럽지 못한 브라우징 경험을 하게 된다.
그래서 모든 브라우저에서 동작하고 아름다운 타이포그래피에 중점을 둔 서비스 형태의 리더 모드를 만들었다. 아주 기본적인 수준이지만 오래된 기기에서 기사를 읽을 때 유용하게 쓰고 있으며, 대역폭이 낮은 지역이나 여행 중에도 콘텐츠 접근성을 높일 수 있을 것이다.
직접 만들어 보기
최근 Hacker News 터미널 클라이언트인 circumflex에 대한 글을 봤다. 이 도구는 웹사이트 콘텐츠를 꽤 잘 렌더링했는데, 이걸 프록시 서버로 개조할 수 있을지 궁금해졌다.
Go로 작성된 정리 코드는 여기에 있다:
func GetArticle(url string, title string, width int, indentationSymbol string) (string, error) {
articleInRawHTML, httpErr := readability.FromURL(url, 5 * time.Second)
if httpErr != nil {
return "", fmt.Errorf("could not fetch url: %w", httpErr)
}
// ...
}이 프로젝트는 Mozilla의 Readability를 포팅한 go-readability를 사용한다. Rust 버전은 readability이며 사용법은 아주 간단하다:
use readability::extractor;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let response = extractor::scrape("https://endler.dev/2022/readable")?;
println!("{}", response.content);
Ok(())
}본격적인 프록시 서버를 만들기 전에, URL을 받아 깔끔하고 읽기 쉬운 HTML 파일을 출력하는 간단한 CLI 도구부터 만들어 보자.
use readability::extractor;
use std::fs::File;
use std::io::Write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// read the URL from the command line
let url = std::env::args().nth(1).expect("Please provide a URL");
let response = extractor::scrape(&url)?;
let mut file = File::create("index.html")?;
file.write_all(response.content.as_bytes())?;
Ok(())
}출력 결과는 이미 놀라울 정도로 깔끔했다. 다음으로 응답 콘텐츠를 감싸는 간단한 HTML 템플릿을 추가했다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
<link rel="stylesheet" href="yue.css" />
<style type="text/css">
body {
margin: 0;
padding: 0.4em 1em 6em;
background: #fff;
}
.yue {
max-width: 650px;
margin: 0 auto;
}
</style>
</head>
<body>
<div class="yue">{{content}}</div>
</body>
</html>당장은 본격적인 템플릿 엔진을 쓸 필요는 없다. str::replace로 {{content}} 플레이스홀더를 실제 콘텐츠로 교체하면 된다. 😉
프록시 설정
프록시 설정은 shuttle을 이용하면 아주 간단하다. zerocal에 이어 shuttle에 호스팅하는 두 번째 프로젝트인데, 과정이 매우 매끄러워서 만족스럽다. 🚀 앱 이름은 readable이라고 하자:
cargo shuttle init --axum --name readable이 명령어는 간단한 hello world 라우트를 가진 작은 Axum 앱을 생성한다.
난관 1: reqwest
readability 크레이트를 프로젝트에 통합하면서 작은 난관에 부딪혔다.
extractor::scrape를 위와 똑같이 사용했고 프록시는 로컬에서 정상적으로 실행됐다. 하지만 프록시를 통해 웹사이트를 가져오려 하자 다음과 같은 오류가 발생했다:
thread 'tokio-runtime-worker' panicked at
'Cannot drop a runtime in a context where blocking is not allowed.
This happens when a runtime is dropped from
within an asynchronous context.'이는 런타임 안에서 또 다른 런타임을 시작했다는 의미였다.
readability 크레이트의 소스 코드를 확인해 보니, reqwest::blocking::Client를 생성해 URL을 가져오고 있었다. 요청이 끝난 뒤 클라이언트가 drop되면서 런타임이 종료되는 것이 원인이었다.
reqwest::blocking::Client 대신 reqwest::Client를 사용해 문제를 해결했다.
// reqwest::blocking::Client
let client = reqwest::blocking::Client::new();
// reqwest::Client
let client = reqwest::Client::new();이제 기사의 콘텐츠는 가져왔지만, 여전히 이를 readability에 전달해야 했다. 다행히 Read를 구현한 타입을 받아 추출된 콘텐츠를 반환하는 extractor::extract라는 함수가 제공된다.
하지만 reqwest::Response는 (reqwest::blocking::Response와 달리) Read를 구현하지 않는다. 그래서 직접 Read가 가능한 타입으로 변환해야 했다.
다행히 reqwest::Response에는 Bytes 객체를 반환하는 bytes 메서드가 있다. 이 Bytes 객체는 Read를 구현하므로 이를 이용해 extractor::extract를 호출할 수 있다.
let body = client.get(&url).await?.text().await?;
let bytes = body.bytes().await?;
let response = extractor::extract(&mut res, &url)?;난관 2: 라우팅
앱은 더 이상 크래시되지 않았지만, 여전히 응답을 받을 수 없었다.
내 라우터는 이렇게 생겼다:
#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
let router = Router::new().route("/:url", get(readable));
let sync_wrapper = SyncWrapper::new(router);
Ok(sync_wrapper)
}알고 보니 /:url을 라우트로 사용하면 /https://example.com 같은 경로와 매칭되지 않는다. :는 첫 번째 슬래시까지의 단일 세그먼트에만 매칭되기 때문이다.
해결책은 /*url을 사용하는 것이었다. 이는 끝까지 모든 세그먼트를 매칭하는 와일드카드 라우트다.
타이포그래피와 레이아웃

첫 번째 프로토타입에서는 보기 좋게 나온 것 중 가장 먼저 찾은 yue.css라는 CSS 프레임워크를 사용했다.
최종 버전에서는 잘 조판된 책을 읽는 느낌을 주어 항상 마음에 들었던 Ruud van Asseldonk의 블로그 스타일을 모방했다.
폰트는 내가 가장 좋아하는 두 가지를 골랐다
- Crimson Pro 본문용.
- JetBrains Mono 코드용.
둘 다 SIL Open Font License 1.1로 라이선스되어 있다.
readable은 터미널에서도 사용할 수 있다.
lynx https://readable.shuttleapp.rs/https://en.wikipedia.org/wiki/Alan_Turing한계점
이 프록시는 완벽과는 거리가 멀다. 개인적으로 쓰려고 몇 시간 만에 만든 것이다.
- 항상 유효한 HTML을 생성하지는 않는다.
- JavaScript가 실행되지 않으므로 일부 웹사이트는 제대로 동작하지 않는다. 어떤 이들은 버그가 아니라 기능이라고 할지도 모르겠다. 😉
- 정교한 페이월이나 봇 감지를 적용한 웹사이트도 마찬가지다. ScrapingBee나 Browserless 같은 헤드리스 브라우저를 쓰면 우회할 수 있겠지만, 프로젝트에 그런 복잡성을 더하고 싶지는 않았다.
readability라이브러리는 문서를 제멋대로 포맷하는 경향이 있다. 그래서 가끔 이상한 결과가 나오기도 한다. 예를 들어 코드 블록을 망가뜨리기를 좋아한다.
크레딧
readability 프록시를 만든 첫 사람은 내가 아니다. 리서치 과정에서 readable-proxy라는 프로젝트를 알게 됐는데, 지금은 중단된 것으로 보인다. 그래도 같은 필요를 느낀 사람이 또 있었다는 걸 알게 되어 반가웠다.
Ruud van Asseldonk에게 블로그를 오픈소스로 공개해 준 것에 감사드린다. 🙏 그의 글과 문서는 언제나 큰 영감이 된다.
결론

가장 인기 있는 브라우저가 광고 차단 기능을 없앨지도 모르는 요즘 같은 때에, 광고나 트래킹 없이 기사를 읽을 수 있는 작은 서비스는 유용할 수 있다. 모든 트래픽을 이 프록시로 보내라는 얘기는 아니지만, 비 오는 날 따뜻한 음료와 훌륭한 글 한 편을 즐길 때 도구함에 넣어 두면 좋은 도구다. ☕
readable 인스턴스를 직접 배포하거나 내가 호스팅하는 것을 사용해도 좋다. 소스 코드는 GitHub에서 볼 수 있다. 혹시 함께 유지보수를 도와줄 분이 있을지도 모르겠다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기