jbiaojerry/ebook-treasure-chest — 별 11.8k짜리 큐레이션 리포가 어떻게 "정적 사이트 + Python 파이프라인"만으로 풀 텍스트 검색을 제공하는지 뜯어본다.
표면은 큐레이션 리스트, 속은 데이터 파이프라인.
겉으로 보면 그냥 중국어 전자책 다운로드 링크를 모아둔 깃허브 저장소다. 樊登读书(판덩 독서), 微信读书(위챗 독서), 京东读书(징둥 독서), 喜马拉雅(시마라야) 같은 중국 독서 앱에 있는 책들의 epub·mobi·azw3 파일을 카테고리별 마크다운에 정리해둔 것. 별 11,813개, 포크 1,677개가 붙은 인기 큐레이션 리포다.
그런데 한 꺼풀 벗기면 이야기가 달라진다. 이 리포는 단순한 README + 링크 모음이 아니라 1000개 마크다운 파일 → JSON 데이터셋 → 정적 검색 사이트로 이어지는 작은 파이프라인이다. 별을 모은 진짜 이유도 이 자동화 구조 덕분에 24,071권을 GitHub Pages에서 실시간 검색할 수 있어서다.
awesome-*로 시작하는 리포들이 대표적인 예다. 별 수가 많아 GitHub 트렌딩 상위권에 자주 오른다.왜 이 리포가 트렌딩 18위에 올랐는가.
모든 데이터를 빌드 타임에 JSON 한 파일로 합쳐서 브라우저에 통째로 던지고, 검색은 클라이언트 JavaScript에서 한다. 인프라 비용은 0원, 서버 다운타임도 없다. "서버 없는 검색 엔진"이라는 발상이 도서 큐레이션과 만나면서 별 1만 개를 끌어모은 것.
트렌딩 이유, 그리고 경쟁 제품과의 차이.
전자책 검색 사이트는 인터넷에 많다. Z-Library, Anna's Archive, Library Genesis 같은 큰 플랫폼이 이미 있는데도 이 작은 깃허브 리포가 별 1만 개를 넘긴 데에는 이유가 있다.
樊登读书·微信读书 같은 중국 독서 구독 앱 전용 책을 epub·mobi·azw3로 변환해 모았다. 일반 도서관 사이트엔 잘 없는 컬렉션이라 중국어 사용자에게 희소가치가 크다.
대형 도서관 사이트는 가입 강제 / VIP 결제 / 광고 팝업이 끼지만, 이 리포는 GitHub Pages에 호스팅된 정적 페이지라 즉시 검색 → 클릭 → 다운로드로 끝난다. 마찰이 거의 없다.
키워드를 입력하면 search.js가 메모리에 올라간 24,071권 배열을 필터링한다. 네트워크 왕복이 없으니 사실상 입력과 동시에 결과가 뜬다. 백엔드 API에 의존하지 않는 구조의 가장 큰 장점이다.
리포 안의 max_book_id_found.txt 값은 64,970. 책 ID를 6만 4천까지 순차 스캔하며 새 책을 발견할 때마다 마크다운에 추가한 흔적이다. 큐레이션이 수작업이 아니라 자동 동기화로 굴러간다.
이 리포가 모아둔 책의 상당수는 원래 유료 구독 앱의 콘텐츠다. 학습 자료로 구조와 파이프라인을 뜯어보는 건 자유지만, 실제로 책을 내려받아 재배포하는 건 본인 책임 영역. 트렌드를 분석할 때는 "구현은 멋지지만 운영 모델은 따라 하면 안 된다"는 점을 기억하자.
단순해 보이지만 세 층이 또렷하다.
이 프로젝트는 거대한 프레임워크 없이 Python + 마크다운 + 바닐라 JavaScript만으로 굴러간다. 풀스택이라기보단 "파이프라인 + 정적 사이트"에 가까운 구조다. 각 층을 따로 보자.
md/ 폴더카테고리 하나당 .md 파일 하나. 예: md/문학.md(2,711권), md/历史.md(1,748권), md/Python.md(28권). 각 파일 안엔 "책 이름 | 저자 | [다운로드](링크)" 표 한 장이 있다. 사람도 깃허브에서 그대로 읽을 수 있고, 동시에 기계도 파싱 가능한 이중 용도 문서다.
max_book_id_found.txt파일 내용은 숫자 한 줄(64970)뿐. "여기까지 ID를 훑었음" 표시기로, 자동 동기화 스크립트(scripts/sync/)가 다음번에 다시 돌 때 이 ID부터 이어서 확인하라는 신호다. 단순하지만 증분 업데이트의 본질이다.
scripts/parse_md_to_json.py (약 200줄)핵심 ETL. 1,000개 .md를 모두 읽어 표를 정규식으로 파싱하고, 각 책에 {title, author, link, category, formats} JSON 객체를 만들어 docs/all-books.json으로 합친다. 결과는 약 7MB짜리 JSON 한 덩어리.
scripts/generate_index.py (약 30KB)all-books.json을 읽어 GitHub Pages용 docs/index.html(검색 UI 포함 완성 HTML)과 docs/books.json을 자동 생성한다. 스크립트 실행 결과는 GitHub Actions generate-site.yml이 main 브랜치에 자동 커밋한다.
scripts/sync/ — 데이터 수집기외부 사이트에서 신간을 가져와 md/에 자동 추가하는 동기화 모듈(레포에 폴더만 노출). 이 모듈이 max_book_id_found.txt를 읽고 그다음 ID부터 다시 스캔한다.
docs/index.html (78KB) + docs/search.js (8KB)바닐라 자바스크립트 단일 페이지. React·Vue·번들러·빌드 시스템이 전혀 없다. 페이지를 열면 fetch("all-books.json")으로 책 7MB를 가져오고, 입력창의 oninput에 300ms 디바운스를 걸어 메모리 배열을 필터링해 보여준다.
docs/ 또는 gh-pages 브랜치를 그대로 정적 호스팅해주는 무료 서비스. 서버 비용 0원으로 YOUR-NAME.github.io/REPO/ 주소를 받는다. 이 리포의 검색 사이트가 여기서 돌아간다..nojekyll 파일
.nojekyll이라는 빈 파일을 두면 "그 단계 건너뛰고 내 파일 그대로 서비스해"라는 신호. 이 리포는 자체 검색 사이트가 있으니 Jekyll이 손대지 못하게 막아둔 것."빌드 타임 ETL + 런타임 클라이언트 검색" 패턴.
이 리포의 진짜 가치는 아키텍처 패턴이다. 같은 발상으로 도서뿐 아니라 강의·노트·논문·맛집·툴 디렉터리 사이트를 다 만들 수 있다. 핵심 패턴을 그림으로 보자.
┌──────────────── 사람이 손대는 영역 ────────────────┐
│ │
│ md/문학.md md/历史.md md/Python.md ... │ ← 카테고리당 1개
│ (마크다운 표: 제목 | 저자 | [다운로드](URL)) │ = 사람도 기계도 읽음
│ │
└──────────────────────────┬───────────────────────┘
│
▼ (사람이 PR로 갱신)
┌──────────── Python 파이프라인 (빌드 타임) ────────┐
│ │
│ scripts/sync/ ← 신간 자동 수집 │
│ │ │
│ ▼ │
│ max_book_id_found.txt ← 진행률 체크포인트 │
│ │ │
│ ▼ │
│ scripts/generate_index.py ← README 색인 갱신 │
│ │ │
│ ▼ │
│ scripts/parse_md_to_json.py │
│ │ 1) md 1,000장 읽기 │
│ │ 2) 정규식으로 표 파싱 │
│ │ 3) 카테고리/포맷 메타 부착 │
│ ▼ │
│ docs/all-books.json (7 MB) ← 단일 데이터셋 │
│ docs/parse-stats.json ← 통계 스냅샷 │
│ │
└──────────────────────────┬───────────────────────┘
│
▼ (git push → 즉시 배포)
┌──────────── GitHub Pages (런타임) ───────────────┐
│ │
│ 브라우저 ─ fetch("all-books.json") ─▶ 메모리 │
│ │ │
│ ▼ │
│ search.js 필터 │
│ (디바운스 300ms)│
│ │ │
│ ▼ │
│ 하이라이트 렌더 │
│ │
│ 백엔드 서버: 없음. DB: 없음. API: 없음. │
│ │
└──────────────────────────────────────────────────┘
SQLite도, Postgres도, JSON 파일도 원본이 아니다. 사람이 읽고 쓸 수 있는 마크다운 표가 단일 출처(single source of truth)다. Python 스크립트가 거기서 JSON을 빌드한다. 이렇게 하면 일반인이 깃허브 웹 UI에서 책을 추가하는 PR을 그대로 보낼 수 있다.
전형적인 풀스택이라면 ElasticSearch나 PostgreSQL FTS로 검색 API를 만들었을 것. 이 리포는 그 모든 걸 "한 번에 다운로드시키고 브라우저에서 필터링"으로 대체했다. 7MB는 한 번만 받으면 캐시되고, 이후 검색은 빛의 속도.
search.js는 먼저 all-books.json(풀 데이터)를 시도하고, 실패하면 books.json으로 폴백한다. books.json은 all-books.json의 동일 내용 사본(generate_index.py가 같은 데이터를 두 경로에 씀)으로 크기도 동일(약 6.8MB)하다. '가벼운 메타데이터'가 아닌 풀 데이터 복사본이며, all-books.json fetch 실패 시 같은 데이터를 다른 경로로 재시도하는 이중 보험 역할이다. Graceful Degradation이라는 클래식 패턴. 빌드가 실패해도 사이트는 살아 있다.
서버가 없으니 입력 검증도 브라우저에서. search.js를 보면 HTML 이스케이프(escapeHtml), 정규식 인젝션 방어(escapeRegex), URL 프로토콜 화이트리스트(http/https만 허용)가 박혀 있다. javascript:로 시작하는 악성 링크를 차단하는 XSS 방어다.
이 구조는 도서관 vs. 책방 카탈로그의 차이와 같다. 도서관(전통 풀스택)은 사서(서버)가 책장에서 책을 찾아 갖다 준다. 이 리포는 카탈로그 책 한 권을 통째로 손에 쥐여주고, 손님이 직접 색인을 넘기게 한다. 사서를 고용할 필요가 없으니 운영비가 0원이다.
이 구조는 데이터가 10MB 안쪽일 때만 깔끔하다. 책이 50만 권으로 늘어 JSON이 100MB가 되면 모바일에서 초기 로딩이 망가진다. 그땐 lunr.js 같은 클라이언트 검색 인덱스를 도입하거나, 서버리스 함수로 검색을 분리해야 한다.
어느 폴더가 사람용이고 어느 폴더가 기계용인지.
ebook-treasure-chest/
├── README.md ← 35KB, 색인 목차 (자동 생성)
├── max_book_id_found.txt ← 5바이트 (숫자 64970만 들어있음)
├── sponsors.md ← 후원자 명단
│
├── .github/ ← 로고·검색 데모 GIF·후원 QR 이미지
│
├── md/ ← 카테고리별 마크다운 1,000개
│ ├── 文学.md ← 2,711권
│ ├── 历史.md ← 1,748권
│ ├── Python.md ← 28권
│ └── ... (1000개)
│
├── scripts/ ← Python 파이프라인
│ ├── parse_md_to_json.py ← md → JSON 변환 ETL
│ ├── generate_index.py ← README 색인 자동 생성
│ ├── generate_search_demo_gif.py← 검색 데모 GIF 만들기
│ └── sync/ ← 신간 자동 수집기
│
└── docs/ ← GitHub Pages 호스팅 루트
├── .nojekyll ← Jekyll 빌드 끄기
├── index.html ← 검색 UI (78KB)
├── search.js ← 클라이언트 검색 로직 (8KB)
├── all-books.json ← 전체 데이터셋 (7MB, 24,071권)
├── books.json ← 폴백용 메타데이터
└── parse-stats.json ← 빌드 통계 스냅샷
md/구조의 핵심. PR이 여기로 들어온다. 파일명이 곧 카테고리 이름이고, 파일 안 표 한 장이 그 카테고리의 책 목록 전부. 일반 깃허브 사용자가 코드 한 줄 안 보고도 책을 추가할 수 있다는 게 이 폴더의 미덕.
scripts/이 폴더는 사용자가 직접 만질 일이 거의 없다. 리포 관리자가 PR 머지 후 또는 정기적으로 돌리는 빌드 도구. parse_md_to_json.py가 매번 1,000개 마크다운을 다시 다 읽어 JSON을 새로 만든다. 증분 빌드 아님이라는 점이 단순함의 비결.
docs/GitHub Pages는 이 폴더 안의 파일들을 그대로 정적 서빙한다. all-books.json은 빌드 산출물이지만 git에 그대로 커밋해서 같이 배포한다. 흔히 데이터의 형상 관리(versioning data)라 부르는 방식.
.github/주로 README에서 참조하는 이미지(로고, 검색 데모 GIF, 후원 QR)가 들어있다. GitHub Actions 워크플로 3개가 .github/workflows/에 공개돼 있다 — generate-site.yml(md 변경 시 index.html 자동 빌드), incremental-sync.yml(신간 증분 수집), full-sync.yml(전체 재동기화). 자동화 파이프라인이 레포 안에서 완결된다.
이 레포에서 진짜로 배울 만한 것들.
parse_md_to_json.py는 길지 않은데 현업에서 자주 쓰는 패턴이 다 들어있다. 정규식, pathlib, defaultdict로 카운팅, JSON 직렬화, 통계 부산물 저장, 에러 파일 추적까지. 처음 데이터 파이프라인을 작성하는 사람이 베껴 가기 좋은 골격.
옵시디언(Obsidian)이나 노션에서 내려받은 마크다운 폴더에 같은 패턴을 적용. 표 대신 YAML frontmatter를 파싱하도록 바꾸면 즉시 자기 데이터셋이 된다.
search.js가 가르쳐주는 건 "리액트가 없을 때 어떻게 검색 UX를 만드는가". setTimeout으로 300ms 디바운스, filter + every로 다중 키워드 AND 매칭, <mark> 태그로 하이라이트. 모든 패턴이 50줄 안에서 끝난다.
리액트 프로젝트에 검색 박스를 만들 때 일단 useDebounce 훅부터 찾는데, 이 레포의 코드를 보면 라이브러리 없이도 6줄로 끝난다는 걸 알 수 있다.
<script>…</script>를 박았는데 그게 다른 사람한테도 동작하면 그게 XSS다.search.js의 방어막을 그대로 가져갈 만하다. (1) escapeHtml로 모든 사용자 출력을 escape, (2) escapeRegex로 정규식 메타문자를 살균, (3) URL 프로토콜 화이트리스트로 javascript:·data:를 차단. 정적 사이트라고 보안을 무시할 수 없다는 좋은 사례.
.nojekyll 한 줄로 빌드 단계를 끄고, docs/ 폴더에 JSON·JS·HTML을 그대로 두면 끝. 도메인 없이도 무료로 배포되는 가장 단순한 방식. 데이터를 깃에 커밋해 함께 배포한다는 점도 핵심 — CDN, S3, 별도 DB가 필요 없다.
max_book_id_found.txt는 한 줄짜리 파일이지만 의미는 크다. "어디까지 했는지 외부에 적어두기"는 모든 크롤링·동기화·배치 시스템의 기본기다. 데이터베이스 없이 텍스트 파일 하나로 체크포인트를 관리하는 가장 미니멀한 형태.
개인 노트북이면 충분.
이 프로젝트는 특별한 하드웨어가 필요 없다. Python 표준 라이브러리(json, re, pathlib, collections)만 쓰기 때문에 외부 의존성도 거의 없다.
Python 3.8 이상이면 scripts/parse_md_to_json.py가 그대로 돈다. 메모리는 1GB 정도면 여유 있고, JSON 7MB를 메모리에 올리는 정도라 노트북 어디서든 무리 없이 돈다.
docs/ 폴더에 들어가서 python -m http.server 8000만 치면 즉시 로컬 호스팅. 브라우저는 모던 브라우저(크롬 90+, 사파리 14+)면 모두 가능. fetch API와 ES6 화살표 함수만 쓰니까 호환성도 문제없다.
모바일에서 처음 한 번은 7MB 다운로드가 있다. 와이파이면 1~2초, LTE면 4~5초. 그 뒤로는 브라우저 캐시에 들어가 즉시 검색된다. 데이터 요금이 신경 쓰이면 첫 방문은 와이파이에서 권장.
난이도별로 5단계. 1번부터 차례대로.
git clone → cd docs → python -m http.server 8000 → 브라우저에서 localhost:8000 열기. 10분 안에 24,071권짜리 검색 엔진이 내 노트북에서 돌아가는 경험이 첫 단추.
md/Python.md(28권)를 텍스트로 읽고, 직접 정규식을 짜서 {title, author, link} 배열을 만들어본다. 그 뒤 parse_md_to_json.py의 정규식과 비교해 누가 더 견고한지 확인. "파싱은 한 줄로 안 끝난다"는 걸 체감하는 단계.
현재 search.js는 제목·저자·카테고리만 본다. epub만 검색 / mobi만 검색 같은 필터 체크박스를 UI에 추가하고, books.filter() 안에서 포맷이 일치하는지 확인하도록 바꿔본다. 디바운스 로직은 그대로 두고 필터 조건만 늘리는 연습.
이 리포 구조를 그대로 베껴 나만의 마크다운 큐레이션 사이트를 만든다. 책 대신 강의·툴·논문·맛집·뮤직비디오 등 본인이 관심 있는 주제로. md/·scripts/·docs/ 3폴더 구조 + GitHub Pages 활성화 + 자기 도메인 연결까지.
lunr.js로 업그레이드현재 구조는 substring 매칭만 한다("자기 계발"을 입력하면 "계발" 없는 책은 못 찾음). lunr.js(가벼운 풀텍스트 검색 라이브러리)로 인덱스를 빌드해서 형태소 단위 매칭으로 바꿔본다. 7MB JSON 대신 압축된 검색 인덱스로 데이터셋도 줄어든다.
4주 코스 — 한 주에 하나씩.
학습 주제: pathlib, 정규식, JSON 직렬화, defaultdict로 카운팅, argparse로 CLI 만들기. 실습: parse_md_to_json.py를 한 줄씩 따라 쳐보고, 카테고리별 평균 책 권수 같은 통계 한 가지를 추가로 출력해보기. 추천 자료: 파이썬 공식 튜토리얼 "Brief Tour of the Standard Library".
학습 주제: 이벤트 디바운스 vs. 쓰로틀, Array.prototype.filter, String.prototype.replace로 하이라이트, DOMParser 안전 사용. 실습: 텍스트 입력 → 디바운스 → 외부 API 호출 → 결과 하이라이트까지 50줄로 끝내는 검색 컴포넌트 작성. React 없는 UX 디자인의 기본기.
학습 주제: 반사형·저장형 XSS의 차이, innerHTML 함정, Content Security Policy(CSP) 헤더, URL 프로토콜 스푸핑. 실습: 일부러 취약한 댓글 폼을 만들고 <script>·onerror=…·javascript:로 우회 시도 → escapeHtml과 URL 화이트리스트로 막기. 추천 자료: OWASP XSS Cheat Sheet.
학습 주제: GitHub Pages 설정, .nojekyll의 의미, GitHub Actions로 빌드 자동화, 시크릿 관리, 캐시 헤더. 실습: 본인 큐레이션 리포에 .github/workflows/build.yml을 추가해서 PR이 들어올 때마다 parse_md_to_json.py가 자동으로 돌고, main에 머지되면 GitHub Pages가 자동 갱신되도록.
이 리포 주변에서 자주 등장하는 용어 정리.
output: export, Astro 등이 SSG다. 이 리포는 SSG를 안 쓰고 직접 Python 스크립트로 같은 일을 한다는 점이 특이.parse_md_to_json.py는 교과서적 ETL이다: md 1000장 추출 → 정규식 변환 → all-books.json 적재.search.js의 all-books.json → books.json 폴백이 정확히 이 패턴.md/ 폴더가 단일 진실. docs/all-books.json은 거기서 파생된 산출물일 뿐이다.max_book_id_found.txt가 그 체크포인트 역할을 한다.target="_blank"로 새 창을 열 때 rel="noopener"를 빼먹으면 새 창에서 window.opener로 원래 페이지를 조작할 수 있는 보안 구멍이 생긴다. search.js는 다운로드 링크에 이걸 정확히 박아뒀다.parse_md_to_json.py를 그대로 베껴 JSON을 굽고, GitHub Pages에 올린다. "스택 없는 검색 사이트"가 어떻게 굴러가는지 체감하는 가장 빠른 방법.escapeHtml, (2) escapeRegex, (3) URL 프로토콜 화이트리스트 — 이 셋을 자동 반사로 떠올리는 게 정적 사이트 개발자의 최소 보안 기본기.last_processed.txt 한 줄로 진행률 기록하기. 가장 단순한 형태가 가장 잘 무너지지 않는다.