wget 미러링과 정반대다 — 헤드리스 Chrome으로 페이지를 실제로 렌더링해서 JS가 만들어 낸 최종 DOM을 스냅샷한 다음, 거기서 스크립트·이벤트 핸들러·추적 코드를 전부 도려내고 CSS·이미지·폰트만 로컬 경로로 끌어내린다. 결과물은 살아있는 사이트처럼 보이지만 아무 코드도 실행하지 않고, 네트워크에 전화를 걸지도 않는다. (저장소: tamnd/kage · 언어 Go 1.26 · 약 9,800 LOC · 라이선스 MIT)clone/, browser/, sanitize/, urlx/, zim/ 같은 실제 패키지 소스를 해부해 "렌더 → JS 제거 → 자산 로컬화 → 정적 사본/단일 파일 패킹" 파이프라인이 어떻게 동작하는지에 초점을 맞춘다.이 레포가 무엇을 하는 물건인가.
kage는 한 줄로 말하면 "JS를 제거하는 오프라인 웹 아카이버"다. 보통 페이지 저장(Ctrl+S)이나 wget으로 받은 사본은 6개월 뒤 열어 보면 빈 화면이거나, 멈추지 않는 스피너이거나, 이미 사라진 분석 서버로 계속 요청을 보낸다. 페이지가 사실은 "남의 자바스크립트를 위한 얇은 클라이언트"였기 때문이다. kage는 진짜 브라우저로 페이지를 끝까지 렌더링한 뒤, 그 결과물에서 스크립트를 전부 뜯어내고 자산만 로컬에 남긴다. 그래서 10년 뒤에 열어도 그냥 열린다.
제공하는 명령은 네 개뿐으로 단순하다. kage clone <url>로 사이트를 복제하고, kage serve [dir]로 로컬에서 미리 보고, kage pack <dir>로 폴더를 단일 ZIM 파일 또는 자기완결 실행파일로 압축하고, kage open <file.zim>으로 그 패킹된 파일을 다시 열어 본다.
수많은 웹 아카이빙 도구가 있는데, kage가 트렌딩에 오른 이유.
웹 아카이빙 도구는 많다. wget --mirror, HTTrack, SingleFile, ArchiveBox… kage가 차별화되는 지점은 두 가지 결정이다 — ① "렌더링 후 캡처"로 JS 사이트의 진짜 내용을 잡고, ② "캡처 후 JS 완전 제거"로 코드가 안 도는 영구 사본을 만든다는 것.
wget은 서버가 처음 내려준 HTML만 받는다. 요즘 사이트는 그 HTML이 거의 비어 있고 JS가 내용을 그려 넣으므로, wget 사본은 빈 껍데기가 되기 일쑤다. kage는 헤드리스 Chrome으로 페이지를 실제로 띄워 JS가 DOM을 다 그릴 때까지(network-idle + 정착 시간) 기다린 뒤, 사람이 봤을 최종 DOM을 직렬화한다. SPA(싱글 페이지 앱)도 정적으로 굳는다.
SingleFile은 페이지를 한 파일로 묶지만 스크립트를 그대로 둘 수 있다. kage는 정반대로 스크립트·이벤트 핸들러·javascript: URL·죽은 preconnect/preload 힌트·IE 조건부 주석에 숨은 script까지 모조리 제거한다. 추적도, 네트워크 호출도, 깜짝 동작도 없다. 결과물은 "프로그램이 아니라 사진"이다.
미러는 폴더라 옮기기 번거롭다. kage는 pack으로 미러 전체를 ZIM(위키피디아를 오프라인으로 나르는 Kiwix 생태계의 표준 포맷) 한 파일로 압축하거나, 아예 자기 자신이 사이트인 단일 실행파일로 만든다. 받는 사람은 kage도, ZIM 리더도 필요 없이 그 파일만 더블클릭하면 된다. macOS .app, Linux AppImage, Windows GUI exe까지 지원한다.
① Chrome 의존. 진짜 브라우저를 돌리므로 Chrome/Chromium이 호스트에 있어야 하고(컨테이너 이미지는 Chromium 내장), wget보다 느리고 무겁다. ② 인터랙티브 기능은 죽는다. JS를 제거하므로 검색창·필터·동적 메뉴 등 클라이언트 동작은 사라진다 — "읽기 전용 박제"가 목적이다. ③ ZIM 전문검색 미지원. 구조적으로 유효한 ZIM을 쓰지만 Kiwix가 만드는 풀텍스트 인덱스는 없어 리더 내 검색이 제한적이다.
go.mod와 실제 import를 뜯어 본 kage의 기술 구성.
kage는 순수 Go 단일 바이너리를 지향한다(기본 빌드는 CGO_ENABLED=0). 외부 의존성은 신중히 골랐고, 핵심은 "브라우저 제어 + HTML 파싱 + 압축 + CLI" 네 갈래다.
| 영역 | 기술 / 라이브러리 | 역할 |
|---|---|---|
| 언어/런타임 | Go 1.26 | 정적 단일 바이너리, 강력한 동시성(goroutine) |
| 브라우저 제어 | go-rod/rod + stealth | DevTools 프로토콜로 헤드리스 Chrome 구동·DOM 스냅샷 |
| HTML 파싱 | golang.org/x/net/html | DOM 트리 파싱·순회·재직렬화 (sanitize의 토대) |
| URL 처리 | golang.org/x/net/publicsuffix | 등록 가능 도메인 판정(서브도메인 범위 결정) — x/net의 서브패키지라 go.mod 의존성은 golang.org/x/net 하나다 |
| CLI 프레임워크 | spf13/cobra + charmbracelet/fang | 명령 트리·플래그·자동완성·세련된 도움말 |
| 터미널 UI | charm.land/lipgloss/v2 | 진행 로그·요약의 스타일링 |
| 압축 | klauspost/compress (zstd) | ZIM 본문 텍스트 zstd 압축 |
| 데이터셋 출력 | parquet-go/parquet-go | 크롤 결과를 Parquet 컬럼형으로 내보내기 |
| 네이티브 창(opt-in) | webview/webview_go | -tags webview 빌드 시 OS 내장 WebView 창 |
| 식별자 | google/uuid | ZIM 아카이브 UUID(콘텐츠 기반 결정적 생성) |
| 배포 | GoReleaser + Docker(GHCR) | 다중 OS 아카이브·deb/rpm/apk·Chromium 내장 이미지·SBOM·cosign 서명 |
kage의 스택을 사진관에 비유하면 — go-rod는 피사체(웹페이지)를 제대로 세팅하고 셔터를 누르는 카메라, x/net/html은 찍힌 필름을 현상해 합성·보정하는 암실, sanitize는 배경에 끼어든 잡티(JS)를 지우는 리터칭, zim/pack은 완성된 사진을 한 권의 앨범(단일 파일)으로 제본하는 제본소다.
go-rod는 Go 네이티브 DevTools 프로토콜 클라이언트다. Node 런타임 없이 Go 바이너리 하나로 Chrome을 제어할 수 있어, "단일 정적 바이너리" 목표와 맞는다. stealth 플러그인은 자동화 탐지(navigator.webdriver 등)를 우회해, 봇 차단이 있는 사이트도 렌더링할 여지를 준다.
한 줄의 URL이 디스크 위의 정적 사본이 되기까지.
kage의 전체 흐름은 README가 압축해 보여 준다: seed URL ▶ headless Chrome(render) ▶ final DOM(snapshot) ▶ strip JS(sanitize) ▶ localise assets(rewrite links) ▶ disk. 이를 실제 패키지 단위로 펼치면 다음과 같다.
urlx 패키지는 네트워크도, 파일시스템도, 시계도 건드리지 않는 순수 함수 집합이다. Normalize가 모든 URL을 정규 키로 접고, LocalPath가 그 키를 디스크 경로로 결정적 매핑한다. 그래서 자산이 아직 다운로드되기도 전에 페이지 워커가 그 자산을 가리키는 링크를 미리 재작성할 수 있다 — 양쪽이 "바이트가 어디 놓일지"에 미리 합의하기 때문. 이게 파이프라인을 단순하게 만드는 핵심이다.
sanitize.Strip은 바이트→트리→바이트를 다 하지만, 클로너는 더 영리하게 sanitize.CleanTree(이미 파싱된 *html.Node를 제자리에서 청소)를 쓴다. 즉 HTML을 한 번만 파싱해서 JS 제거기와 자산 재작성기가 같은 트리를 공유한다. 같은 문서를 두 번 파싱하는 낭비를 없앤 설계.
페이지 렌더링(무겁고 Chrome 탭이 한정적)과 자산 다운로드(가볍고 plain HTTP)는 성격이 달라 별도 워커 풀로 분리했다. browser.Pool은 세마포어(chan struct{})로 동시 탭 수를 제한하고(기본 4), asset.Downloader는 독립적으로 HTTP를 친다. --workers(기본 4, README에 문서화)가 렌더 풀 크기이고, 자산 다운로드 풀은 --asset-workers(기본 8)로 따로 조절한다.
각 페이지는 "쓰여질 파일"로 키가 매겨져, http/https·트레일링 슬래시 유무와 무관하게 같은 글은 정확히 한 번만 가져온다. frontier의 visited 집합은 _kage/state.json에 저장돼, Ctrl-C로 끊어도 다시 실행하면 멈춘 곳부터 이어 간다(--refresh는 제자리 재렌더, --force는 호스트 삭제 후 새로).
가장 핵심인 JS 제거를 코드 수준으로 보면, golang.org/x/net/html로 파싱한 트리를 재귀 순회하며 노드별로 처리한다.
// sanitize.CleanTree — DOM 트리를 제자리에서 청소 (요지)
switch c.DataAtom {
case atom.Script: // <script> 통째로 삭제
n.RemoveChild(c); rep.ScriptsRemoved++
case atom.Noscript: // 기본은 삭제, KeepNoscript면 본문으로 펼침
n.RemoveChild(c)
case atom.Meta: // JS 타깃 meta-refresh 제거
if isMetaRefresh(c) { n.RemoveChild(c) }
case atom.Link: // 오프라인에서 무의미한 preconnect/preload 제거
if isDeadLink(c) { n.RemoveChild(c) }
}
stripHandlers(c, rep) // onclick, onload 등 on* 속성 제거
neutralizeJSURLs(c, rep) // href="javascript:..." 무력화
특히 영리한 부분은 IE 조건부 주석 처리다. <!--[if lt IE 9]><script src=...><![endif]-->은 하나의 "주석 노드"로 파싱되는데, 그 안에 <script>가 텍스트로 숨어 있다. 요소만 순회하면 이 스크립트를 못 보고 지나치므로, kage는 조건부 주석을 통째로 삭제해 주석에 밀반입된 JS까지 차단한다. 또 문서에 charset이 없으면 ensureCharset으로 추가해 오프라인에서 한글·UTF-8이 깨지지 않게 한다.
JS를 결국 지울 거면서 왜 무겁게 Chrome으로 실행부터 시킬까? 답은 "실행 결과(DOM)는 남기되 실행 능력(JS)은 없앤다"는 것. 정적 HTML만 받으면(=wget) 빈 페이지가 되고, JS를 남기면(=SingleFile 일부) 박제가 아니다. kage는 "JS가 한 일의 결과만 화석으로 굳히는" 중간 길을 택했고, 그래서 렌더 후 제거라는 2단계가 필수다.
"관심사별로 쪼갠다" — kage 저장소는 패키지 경계가 곧 설계도다.
kage는 README가 직접 밝히듯 "관심사로 분할(split by concern)"된 구조다. 각 디렉토리가 파이프라인의 한 단계에 정확히 대응한다.
이 구조의 미덕은 의존성이 한 방향으로 흐른다는 것이다. cli가 clone을 부르고, clone이 browser·sanitize·asset·urlx를 조립한다. 가장 밑바닥의 urlx는 아무것도 의존하지 않는 순수 패키지라, 단위 테스트가 쉽고 전체를 추론하기 좋다(각 패키지마다 *_test.go가 동반된다).
이 레포를 읽으며 실제로 손에 남기면 좋은 것들.
"JS 사이트를 어떻게 캡처하나"의 답이 여기 있다. browser/pool.go는 페이지로 이동(navigate) → 네트워크가 조용해질 때까지 대기(settle, 기본 1.5초) → (옵션) 자동 스크롤로 lazy 이미지 트리거 → 최종 DOM 직렬화의 순서를 보여 준다. 스크래핑·E2E 테스트·스크린샷 서비스의 토대가 되는 패턴이다.
// autoScroll — lazy-load 이미지를 끌어내기 위해 페이지를 단계적으로 스크롤
page.Eval(`() => new Promise((resolve) => {
let total = 0; const step = 400;
const timer = setInterval(() => {
window.scrollBy(0, step); total += step;
if (total >= document.body.scrollHeight) {
clearInterval(timer); window.scrollTo(0, 0); resolve();
}
}, 100);
})`)
정규식으로 HTML을 자르는 건 위험하다. kage는 golang.org/x/net/html로 제대로 된 DOM 트리를 만들어 재귀 순회하며 노드를 삭제·치환한다. "트리를 한 번만 파싱해 여러 변형기가 공유"하는 CleanTree 패턴은 어떤 HTML 처리기에도 응용된다.
크롤러의 영원한 골칫거리인 "같은 페이지를 여러 URL로 만나는 문제"를 urlx.Normalize가 정규화로 푼다. 그리고 경로를 해시 충돌 없이 결정적으로 만들어, 자산이 받아지기 전에 링크를 미리 재작성하는 비동기 파이프라인을 가능케 한다. "순수 함수로 부수효과를 격리"하는 설계의 교과서.
chan struct{} 세마포어로 동시 실행 수를 제한하고, sync.WaitGroup·sync.Mutex로 공유 상태(seenAssets, frontier)를 보호하는 전형적이고 견고한 Go 동시성 패턴을 실전 코드로 볼 수 있다.
zim/은 외부 라이브러리 없이 순수 Go로 ZIM 포맷을 읽고 쓴다. 헤더·디렉토리 엔트리·클러스터·zstd 압축을 직접 구현한 것이라, 파일 포맷 설계와 직렬화를 배우는 살아있는 예제다. "패킹이 결정적(같은 입력→바이트 동일 출력)"이라 체크섬·캐시가 안전하다는 점도 주목할 만하다.
kage의 sanitize는 Strip([]byte) ([]byte, Report, error)라는 깔끔한 인터페이스다. 임의의 HTML 한 덩이를 넣어 JS가 얼마나 제거됐는지 Report로 확인하는 작은 프로그램을 만들어 보면, "안전한 HTML 살균"이 어떻게 동작하는지 손에 잡힌다. XSS 방어·메일 클라이언트 HTML 정제와도 직결된다.
kage를 돌리려면 무엇이 필요한가.
| 항목 | 요구사항 | 비고 |
|---|---|---|
| 실행 환경 | Go 1.26+ (소스 빌드 시) 또는 프리빌트 바이너리 | go install github.com/tamnd/kage/cmd/kage@latest |
| 필수 외부 의존 | Chrome 또는 Chromium | kage가 시스템 설치를 자동 탐지. --chrome/KAGE_CHROME로 지정 가능 |
| 컨테이너 사용 시 | Docker만 있으면 됨 | GHCR 이미지에 Chromium 내장 — 호스트에 Chrome 불필요 |
| 메모리 | Chrome 탭 수 × 페이지 크기에 비례 | --workers로 동시 탭 수 조절(기본 4), 사양 낮으면 줄일 것 |
| 네이티브 창 빌드 | cgo + 플랫폼 WebView | -tags webview 옵트인. 기본 빌드는 순수 Go(CGO 끔) |
| OS | Linux / macOS / Windows | 릴리스가 전 플랫폼 아카이브·deb/rpm/apk 제공 |
kage는 자체적으로 브라우저를 받지 않고 호스트의 Chrome/Chromium을 빌려 쓴다. 설치가 없거나 못 찾으면 렌더가 실패한다. 가장 쉬운 회피책은 컨테이너 이미지(ghcr.io/tamnd/kage) 사용 — Chromium이 들어 있어 추가 설치가 필요 없다. docker run --rm -v "$PWD/out:/out" ghcr.io/tamnd/kage clone example.com.
"읽기"에서 "하기"로 — 난이도별 5단계.
작은 정적 블로그 하나를 kage clone example.com --max-pages 20으로 복제하고, kage serve ~/data/kage/example.com으로 띄워 본다. 브라우저 개발자도구 Network 탭을 열어 외부로 나가는 요청이 0인지 확인 — JS 제거가 실제로 됐다는 증거다.
JS로 내용을 그리는 사이트(예: 일부 SPA)를 wget과 kage로 각각 받아 결과 HTML을 비교한다. wget 사본은 비어 있고 kage 사본엔 내용이 있는 차이를 직접 눈으로 확인하면 "렌더 후 캡처"의 가치가 와닿는다.
큰 문서 사이트에서 한 섹션만 복제: kage clone go.dev --scope-prefix /doc --max-depth 2 --scroll. --exclude로 특정 경로를 빼고, Ctrl-C로 끊은 뒤 다시 실행해 재개(resume)가 동작하는지(_kage/state.json 확인) 검증한다.
kage pack example.com으로 ZIM을 만들고 kage open으로 다시 열어 본다. 이어 kage pack example.com --format binary -o site로 실행하면 자기 자신이 사이트가 되는 단일 파일을 만들어, kage 없이 ./site만으로 서빙되는지 확인한다.
작은 Go 프로그램에서 github.com/tamnd/kage/sanitize를 import해 임의 HTML을 Strip하고, 반환된 Report(ScriptsRemoved, HandlersRemoved 등)를 출력해 본다. 더 나아가 Options.KeepNoscript 동작 차이를 실험하며 HTML 살균 로직을 내 프로젝트에 이식한다.
kage를 발판으로 "웹 자동화 + 시스템 프로그래밍"을 4주에 쌓는다면.
| 주차 | 주제 | 도달 목표 |
|---|---|---|
| 1주차 | Go 기초 + 동시성 | goroutine·channel·WaitGroup·세마포어 패턴 이해, kage의 워커 풀 코드 읽기 |
| 2주차 | HTML/DOM 처리 | x/net/html로 트리 파싱·순회·변형, 작은 HTML 살균기 직접 작성 |
| 3주차 | 헤드리스 브라우저 자동화 | go-rod(또는 Playwright)로 navigate·settle·DOM 스냅샷·스크린샷 구현 |
| 4주차 | 크롤러 설계 + URL 정규화 | BFS frontier·중복제거·robots.txt·재개 상태 영속화 설계 |
| 이후 | 바이너리 포맷 / 패키징 | ZIM·zstd 압축 이해, Go 바이너리에 데이터 append, GoReleaser 배포 |
kage는 "작지만 완성도 높은 Go 프로젝트"의 좋은 표본이다. 한꺼번에 다 이해하려 말고 한 패키지씩(특히 의존 없는 urlx → sanitize → browser 순서로) 테스트 코드와 함께 읽는 것이 가장 빠르다. 각 패키지가 단일 책임을 지므로, 하나를 떼어 내 다른 프로젝트에 재사용하기도 쉽다.
kage를 읽다 막히는 용어 빠른 참조.
| 헤드리스 Chrome | 화면 없이 백그라운드로 도는 Chrome. JS를 실제로 실행해 페이지를 렌더링한다. |
| DevTools 프로토콜 | Chrome을 외부에서 원격 제어하는 디버깅 프로토콜(CDP). go-rod가 이걸로 통신한다. |
| go-rod | Go용 DevTools 프로토콜 클라이언트. Node 없이 Chrome을 제어한다. |
| DOM 스냅샷 | JS가 다 그린 뒤의 최종 문서 구조(DOM)를 HTML 문자열로 직렬화한 것. |
| settle / network-idle | 페이지 로드 후 네트워크 요청이 잠잠해지는 구간. 이때 DOM이 안정됐다고 보고 캡처한다. |
| sanitize | (여기선) HTML에서 스크립트·핸들러·javascript: URL을 제거해 무해하게 만드는 것. |
| javascript: URL | href="javascript:..."처럼 링크에 숨은 실행 코드. kage가 무력화한다. |
| 조건부 주석 | 구형 IE용 <!--[if IE]>...<![endif]-->. 안에 script가 숨을 수 있어 통째로 제거. |
| frontier | 크롤러가 아직 방문 안 한 URL들의 큐. 중복을 제거해 같은 페이지를 한 번만 받는다. |
| 멱등성(idempotent) | 같은 입력에 몇 번을 실행해도 결과가 같은 성질. kage는 같은 글을 정확히 한 번만 쓴다. |
| 정규화(Normalize) | 다르게 생긴 URL들을 같은 자원이면 하나의 정규 키로 접는 것. |
| ZIM | 웹사이트/위키 전체를 압축·인덱싱한 단일 읽기전용 파일 포맷. Kiwix 생태계 표준. |
| Kiwix | ZIM 파일을 읽어 위키피디아 등을 오프라인으로 보게 해 주는 프로젝트/앱. |
| zstd | 구글이 만든 빠르고 압축률 좋은 압축 알고리즘. kage가 ZIM 본문 압축에 사용. |
| WebView | OS에 내장된 웹 렌더링 엔진(WKWebView·WebView2·WebKitGTK). 네이티브 창에 쓴다. |
| cgo | Go에서 C 라이브러리를 호출하는 다리. WebView 빌드에 필요(기본 빌드는 끔). |
| SPA | Single Page App. 한 HTML에서 JS가 화면을 갈아끼우는 앱. wget이 못 잡는 대표 사례. |