느린 웹을 위한 리더 모드 프록시

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

느린 웹
저는 느린 웹 운동과 한 가지 일을 잘하는 작은 사이트를 무척 좋아합니다.
복잡한 요소 없이 긴 글을 읽을 때는 Firefox의 리더 보기를 사용합니다. 항상 작동하는 것도 아니고 가장 예쁜 것도 아니지만, 저는 좋아합니다.
다른 브라우저에도 리더 모드가 있지만, Chrome처럼 일부 브라우저는 이를 기능 플래그 뒤에 숨겨 둡니다. eBook 리더에 있는 브라우저처럼 리더 모드 자체가 없는 브라우저도 있습니다. 그래서 주로 읽는 데 쓰는 기기에서는 만족스럽지 않고 느린 웹 브라우징 경험을 하게 됩니다.
그래서 모든 브라우저에서 작동하며 아름다운 타이포그래피에 중점을 둔 서비스형 리더 모드를 만들었습니다. 아주 기본적이지만, 오래된 기기에서 글을 읽는 데 쓰고 있습니다. 대역폭이 낮은 지역이나 여행 중에 콘텐츠 접근성을 높이는 데도 도움이 될 수 있습니다.
만들기
최근 Hacker News 터미널 클라이언트인 circumflex에 관한 글을 봤습니다. 이 도구는 웹사이트 콘텐츠를 훌륭하게 렌더링했고, 이것을 프록시 서버에 적용할 수 있지 않을까 생각했습니다.
Golang 정리 코드는 여기에 있습니다.
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)
}
// ...
}여기서는 go-readability, 즉 Mozilla의 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을 쓰면 프록시 설정은 아주 간단합니다. shuttle에서 호스팅한 zerocal에 이은 두 번째 프로젝트인데, 과정이 매우 매끄러워서 무척 만족하고 있습니다. 🚀 앱 이름은 readable로 하겠습니다.
cargo shuttle init --axum --name readable그러면 간단한 hello world 라우트가 있는 작은 Axum 앱이 만들어집니다.
첫 번째 난관: 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을 가져오고 있었습니다. 요청 뒤에는 클라이언트가 드롭되면서 런타임이 종료됩니다.
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는 Read를 구현하지 않습니다(reqwest::blocking::Response와는 대조적입니다). 따라서 직접 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)?;두 번째 난관: 라우팅
앱은 더 이상 크래시하지 않았지만, 여전히 응답을 받지 못했습니다.
라우터는 다음과 같았습니다.
#[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을 대신 쓰는 것이었습니다.
타이포그래피와 레이아웃

첫 번째 프로토타입에서는 보기 좋다는 이유로 처음 찾은 CSS 프레임워크인 yue.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라이브러리는 문서를 원하는 대로 포맷하는 데 상당히 큰 재량을 행사합니다. 때때로 이상한 결과를 낼 수 있습니다. 예를 들어 코드 블록을 엉망으로 만드는 것을 아주 좋아합니다.
감사의 말
제가 가독성 프록시를 처음 만든 사람은 아닙니다. 조사하면서 readable-proxy를 알게 됐지만, 이 프로젝트는 중단된 듯합니다. 그래도 다른 사람들도 같은 필요를 느꼈다는 사실을 보니 좋았습니다.
자신의 블로그를 오픈 소스로 공개해 준 Ruud van Asseldonk에게 감사드립니다. 🙏 그의 글과 문서는 언제나 제게 큰 영감의 원천입니다.
맺음말

가장 인기 있는 브라우저가 광고 차단기를 없애 버릴지도 모르는 시대에 광고나 추적 없이 글을 읽을 수 있는 작은 서비스는 유용할 수 있습니다. 모든 트래픽을 여기에 보내야 한다는 말은 아닙니다. 하지만 비 오는 날, 따뜻한 음료, 훌륭한 글과 함께할 때를 위해 도구 상자에 넣어 둘 만한 좋은 도구입니다. ☕
자신만의 readable 인스턴스를 배포해도 좋고, 제가 호스팅하는 인스턴스를 사용해도 좋습니다. 소스 코드는 GitHub에서 볼 수 있습니다. 이 프로젝트 유지 관리를 도와주고 싶은 분이 계실지도 모르겠습니다.
글을 무작위로 읽기