CLI·별도 데스크톱 앱
이 저장소가 대체 무엇인가.
douyin-downloader는 抖音(Douyin, 중국 본토판 TikTok)의 콘텐츠를 터미널 명령 한 줄 또는 설정 파일 하나로 대량 수집하는 파이썬 도구다. 단일 영상부터 특정 크리에이터의 전체 게시물·좋아요·컬렉션·음악·즐겨찾기까지, 그리고 라이브 방송 녹화까지 다룬다.
앱에서 영상을 보는 건 자판기 앞에서 버튼을 눌러 캔을 받는 것과 같다. 하지만 자판기(앱)는 캔에 스티커(워터마크)를 붙이고, 한 번에 하나씩만 준다. 이 도구는 자판기가 재고를 채울 때 쓰는 뒷문(내부 API)을 찾아, 스티커 붙기 전의 원본 캔을 트럭째 받아온다.
문제는 그 뒷문에 "직원만 아는 암호 노크"(요청 서명)가 걸려 있다는 것. 이 프로젝트 코드의 절반은 그 노크 소리를 정확히 재현하는 데 쓰인다.
Python CLI 선언 버전은 2.0.0이며 REST 서버 모드도 포함한다. 공개 코드는 MIT다. 별도로 안내하는 Douzy 데스크톱은 플러그인·로컬 구성과 활성화 조건을 갖추며 CLI와 지원 범위를 구분한다.
이 도구는 비공개 API를 역이용하고 워터마크(저작자 표시)를 제거한다. 이는 대상 플랫폼의 이용약관 위반일 수 있고, 남의 창작물을 무단 재배포하면 저작권 침해가 된다. 이 문서는 스크래핑·비동기·안티봇 우회의 소프트웨어 엔지니어링을 학습하기 위한 자료이지, 무단 수집을 권하는 것이 아니다. 실제 사용은 본인이 올린 콘텐츠 백업 등 합법적 범위로 한정할 것.
현재 README는 CLI가 Douyin만 지원한다고 명시한다. 개별 영상·사진, 컬렉션·음악·좋아요·즐겨찾기는 요청 검증 때문에 막히며, 쿠키 갱신이나 단순 재시도로 해결되지 않는다고 안내한다. 프로필 게시물은 Playwright 경로를 시도할 수 있으나 성공을 보장하지 않는다. 아래 모드·팩토리 설명은 코드가 가진 분기 구조이며 현재 모든 다운로드가 동작한다는 뜻은 아니다.
Douzy 데스크톱은 Douyin·TikTok·YouTube·Telegram·X를 안내한다. Telegram은 구성·로그인, X는 계정 쿠키가 필요하며 플러그인·로컬 구성과 활성화 조건이 따로 있다. 이를 무료 다중 플랫폼 CLI로 소개하지 않는다.
증분 설정 increase.post: true는 이미 받은 항목을 건너뛰고, false는 선택 범위의 파일을 다시 받아 덮어쓴다. redownload_missing_files는 기본 true이며 실제 주 미디어 파일의 존재·비어 있지 않음을 확인한다. 다시 받기 위해 DB 전체를 지우는 방식은 필요하지 않다.
core/user_modes/post_strategy.py는 목록이 잘렸을 때 incomplete_reason을 남기고 브라우저가 실제 새 항목을 추가했는지 확인한다. core/live_downloader.py는 HLS 소스일 때 재생 가능한 영상 대신 m3u8 목록만 저장한다고 경고한다. 라이브 녹화는 실험 기능이다.
트렌딩 이유와, 흔한 "다운로더 스크립트"와의 결정적 차이.
GitHub에 널린 다운로더 대부분은 requests.get() 한 뭉치의 스파게티 스크립트다. 이 프로젝트는 다르다. 파이썬 모듈이 core / control / storage / auth / config / utils로 계층 분리돼 있고, 60개 이상의 테스트 파일(property-based 테스트 포함)과 Docker 구성과 심지어 각 폴더마다 AGENTS.md(AI 코딩 에이전트용 규칙서)까지 있다.
보통 다운로더 = 라면 하나 끓이는 즉석 조리. 이 프로젝트 = 주방을 구역별로 나누고(전처리·조리·플레이팅), 레시피를 문서화하고, 위생 점검(테스트)까지 통과시킨 영업용 주방. 배우기엔 후자가 압도적으로 값지다.
더우인 서버는 자동화 요청을 막으려고 요청마다 X-Bogus·A-Bogus라는 서명 파라미터를 요구한다. 브라우저가 자바스크립트로 만들어 붙이는 값인데, 이 프로젝트는 그 알고리즘을 파이썬으로 포팅(이식)해 두었다(utils/xbogus.py, utils/abogus.py). 여기에 더해:
즉, "서버가 막으면 어떻게 우회하나"의 여러 계층을 한 코드베이스에서 관찰할 수 있다.
Python CLI는 로컬에서 실행하지만 플랫폼 요청은 외부로 나가며 선택한 전사 API·알림 채널도 외부 서비스를 사용한다. 로컬 실행을 데이터가 전혀 외부로 전송되지 않는다는 뜻으로 읽으면 안 된다. 실제 다운로드 가능 범위와 선택 기능의 연결 대상을 먼저 확인한다.
대상이 더우인이라는 점은 부차적이다. 진짜 가치는 비동기 네트워크 프로그래밍 · 안티봇 우회 · 견고한 재시도/속도제어 · 계층형 아키텍처 · 전략/팩토리 패턴을 실전 규모의 코드로 통째로 볼 수 있다는 것.
무엇으로 만들어졌나 — 계층별로.
| 기술 | 역할 | 왜 이걸 썼나 |
|---|---|---|
| Python ≥3.9 | 전체 언어 | 스크래핑 생태계(요청·파싱) 풍부, 3.9~3.12 지원 |
| asyncio | 동시성 엔진 | 수백 개 다운로드를 스레드 없이 동시 처리(I/O 대기 시간 활용) |
| aiohttp | 비동기 HTTP 클라이언트 | API 호출·파일 다운로드를 논블로킹으로 |
| httpx | 보조 HTTP | storage/file_manager.py가 런타임에 사용(요구사항에 명시 고정) |
| aiofiles | 비동기 파일 I/O | 디스크 쓰기도 이벤트 루프를 막지 않게 |
| aiosqlite | 비동기 SQLite | 다운로드 이력 DB를 async로 조회/기록 |
| 기술 | 역할 |
|---|---|
| xbogus.py | X-Bogus 서명 생성(구형 엔드포인트용). Evil0ctal 프로젝트 기반 Apache-2.0 이식 |
| abogus.py | A-Bogus 서명 + 브라우저 지문(fingerprint) 생성(신형 엔드포인트용, 선택적) |
| gmssl | 중국 국산 암호(SM3 등) — 서명 알고리즘 내부 해시에 필요 |
| auth/cookie_manager, ms_token_manager | 쿠키·ms_token 로드/정제/갱신, 민감 쿠키 차단 목록 관리 |
| 모듈 | 역할 |
|---|---|
| control/rate_limiter.py | 초당 요청 수 제한(기본 2 req/s) + 랜덤 지터로 봇 탐지 회피 |
| control/retry_handler.py | 지수 백오프 재시도(1s→2s→5s, 기본 3회) |
| control/queue_manager.py | 동시 다운로드 개수 제한(기본 5) — 세마포어식 큐 |
| 기술 | 역할 |
|---|---|
| SQLite (aiosqlite) | 다운로드 이력·중복제거·증분 다운로드의 단일 진실원 |
| rich | 터미널 진행바·컬러 로그(예쁜 CLI UX) |
| imageio-ffmpeg (0.6.0 고정) | 번들 ffmpeg 정적 바이너리 — 음성 추출 후 전사(transcription)에 사용 |
| PyYAML | config.yml 설정 파싱 |
| Playwright optional [browser] | 실제 브라우저 폴백(페이지네이션 차단·CAPTCHA 대응) |
| openai-whisper optional [transcribe] | 영상 음성 → 자막 텍스트 전사 |
| FastAPI + uvicorn optional [server] | --serve REST API 서버 모드 |
| pytest + hypothesis + ruff [dev] | 테스트(속성기반 포함) + 린트 |
pip install "douyin-downloader[browser]"처럼 대괄호로 골라 까는 추가 패키지 묶음. 무거운 Playwright·Whisper를 안 쓰는 사람은 안 깔아도 되게 하는 배려.데이터가 링크 한 줄에서 파일까지 흐르는 전 과정.
DownloaderFactory.create(url_type, ...)가 URL 종류에 따라 알맞은 다운로더 객체를 만들어 돌려준다. 호출부(cli/main.py)는 "어떤 다운로더가 필요한지" 몰라도 되고, 새 종류(예: 라이브)를 추가할 때 팩토리에 분기 한 줄만 더하면 된다.
# core/downloader_factory.py — 타입 → 객체 매핑 (핵심만)
if url_type == "video": return VideoDownloader(**common_args)
elif url_type == "user": return UserDownloader(**common_args)
elif url_type == "collection": return MixDownloader(**common_args)
elif url_type == "music": return MusicDownloader(**common_args)
elif url_type == "live": return LiveDownloader(**common_args)
# common_args = config·api_client·file_manager·database·
# rate_limiter·retry_handler·queue_manager ... (의존성 주입)
한 크리에이터를 긁을 때도 게시물(post) · 좋아요(like) · 컬렉션(mix) · 음악(music) · 즐겨찾기(collect)는 API 엔드포인트와 페이지네이션 규칙이 제각각이다. 이걸 core/user_modes/ 아래 전략 클래스로 하나씩 떼어냈다. base_strategy.py가 공통 뼈대, 각 *_strategy.py가 모드별 차이만 구현한다.
전략 패턴은 "교체형 드릴 비트"다. 드릴 본체(UserDownloader)는 하나지만, 나무용·콘크리트용·금속용 비트(전략)를 상황에 맞게 갈아 끼운다. 새 재질이 나와도 비트만 새로 만들면 본체는 안 건드린다.
APIClient·RateLimiter·RetryHandler·Database 같은 무거운 객체는 한 번 만들어 모든 다운로더에 주입한다. 덕분에 속도 제한이 전체에 일관 적용되고, DB 커넥션도 공유된다. common_args 딕셔너리가 그 주입 통로다.
# control/rate_limiter.py — 지터가 "락 안"에 있는 이유
async with self._lock:
# 최소 간격(1/2초) 못 채웠으면 그만큼 대기
if time_since_last < self.min_interval:
await asyncio.sleep(self.min_interval - time_since_last)
# 지터도 락 안에서 — 다음 요청이 "실제 발사 시각" 기준으로 대기하도록
await asyncio.sleep(random.uniform(0, 0.5))
self.last_request = time.time()
지터를 락 밖에 두면 두 요청이 거의 동시에 나갈 수 있다 — 코드 주석이 이 함정을 정확히 짚는다. pyproject.toml조차 "imageio-ffmpeg 0.6.0은 macOS arm64 휠을 처음 제공하는 버전이라 고정" 같은 결정의 이유를 남긴다. 협업·유지보수를 진지하게 고려한 코드베이스라는 신호.
더우인이 status_code == 2483(또는 "请先登录=먼저 로그인하세요")를 주면 LoginRequiredError가 발생하고, cli/main.py의 _run_with_relogin 래퍼가 이를 잡아 대화형 재로그인 후 자동 재시도한다. 긴 배치 작업 중 세션이 끊겨도 멈추지 않게 하는 실전 배려다(docs/superpowers/specs/2026-06-25-auto-relogin-design.md에 설계 문서까지 있음).
폴더 이름만 봐도 책임이 보이도록 나눴다.
core = 무엇을 받나, control = 어떻게 안전하게 받나, storage = 어디에 어떻게 쌓나, auth = 어떻게 서버를 통과하나. 이 4개 폴더만 이해하면 전체가 손에 잡힌다. 나머지(cli·config·utils·server)는 이들을 감싸는 껍데기·도구.
주목할 관례: 폴더마다 AGENTS.md가 있어 Claude Code 같은 AI 에이전트가 그 폴더를 수정할 때 지켜야 할 규칙을 명시한다. 또 CLAUDE.md는 이 CLI가 데스크톱 앱과 백엔드 코드를 공유하니 "공유 로직을 고치면 양쪽에 똑같이 반영하라"고 지시한다. "사람+AI 협업"을 전제로 설계된 최신 리포의 전형.
이 코드에서 구체적으로 무엇을 훔쳐 배울 수 있나.
튜토리얼의 asyncio.sleep 예제를 넘어, 실제 네트워크·파일·DB를 전부 async로 엮은 코드를 볼 수 있다. aiohttp(요청)·aiofiles(디스크)·aiosqlite(DB)가 한 이벤트 루프 위에서 협력한다. asyncio.Lock으로 공유 상태(마지막 요청 시각)를 보호하는 법도 rate_limiter에서 배운다.
utils/xbogus.py는 커스텀 문자셋("Dkdpgh4ZKsQB80/Mfvw...")과 자체 base64·해시로 서명을 조립한다. "브라우저 JS가 하는 일을 서버 언어로 재현"하는 리버스 엔지니어링의 살아있는 예다. (이 파일은 Evil0ctal의 Douyin_TikTok_Download_API 프로젝트에서 온 Apache-2.0 코드다.)
실서비스 스크래퍼가 반드시 갖춰야 할 속도 제한 + 지수 백오프 재시도 + 동시성 제한 + 지터를 각각 독립 모듈로 구현한 참고서. 이 패턴은 더우인뿐 아니라 어떤 API 클라이언트에도 그대로 이식 가능하다.
팩토리·전략·DI 덕에 각 부품을 가짜(mock)로 갈아끼워 단위 테스트할 수 있다. tests/의 60여 개 파일, 특히 hypothesis 기반 속성 기반 테스트(무작위 입력 수천 개로 불변식 검증) — test_notifier_redactor_property.py(민감정보 마스킹이 항상 성립하는가), test_proxy_validator_parity.py 등을 읽어볼 것.
control/rate_limiter.py는 40줄 남짓이고 외부 의존이 없다. 내가 만든 아무 API 호출 루프에 붙여 "초당 N회 + 지터"를 걸어보고, 지터를 락 밖으로 빼면 어떻게 동시 발사가 생기는지 로그로 확인해보라.
돌리는 데 뭐가 필요한가.
| 항목 | 요구 |
|---|---|
| Python | 3.9 이상 (pyproject.toml의 최소 조건) |
| OS | Windows / macOS / Linux (크로스플랫폼). Docker 이미지 제공 |
| 기본 설치 | pip install -e . — aiohttp·httpx·rich·SQLite 등 경량 |
| 디스크 | 다운로드 대상만큼. 도구 자체는 수십 MB(ffmpeg 정적 바이너리 포함) |
| 브라우저 폴백 | [browser] 설치 + Playwright 크로미움(수백 MB) |
| 전사(자막) | [transcribe] Whisper — CPU로도 되나 GPU면 빠름. OpenAI 호환 전사 API 키(선택) |
| 필수 준비물 | 더우인 쿠키(로그인 상태). 즐겨찾기·좋아요 등은 로그인 필수 |
| 네트워크 | 중국 서비스라 지역/프록시 설정이 필요할 수 있음(proxy 설정 지원) |
핵심은 무겁지 않다는 것. GPU도, 대용량 RAM도 기본 사용엔 불필요하다. 병목은 오직 네트워크 대역폭과 서버의 속도 제한이다 — 그래서 코드가 속도제한·재시도에 그토록 공을 들인다.
난이도별로 — 코드를 읽고 손대며 배우기. (합법적 범위: 본인 콘텐츠·공개 학습용 소규모로)
core/url_parser.py의 _extract_video_id 등 정규식이 /video/(\d+) 같은 패턴으로 ID를 뽑는 과정을 따라가고, 각 URL 타입(video·user·collection·gallery·music·live)이 어떤 ID로 매핑되는지 표로 정리해보라.
config.example.yml을 복사해 쿠키를 넣고, 본인 계정에 올린 테스트 영상 링크 하나로 douyin-dl --url ...을 실행. Downloaded/작성자/날짜_제목/ 폴더 구조와 함께 저장되는 *.json 메타데이터·download_manifest.jsonl을 열어 내용을 확인하라.
설정에서 rate_limit과 동시성(concurrency)을 낮췄다/높였다 하며 로그의 재시도 빈도를 비교. retry_handler의 백오프가 실제로 1s→2s→5s로 늘어나는지 로그로 검증하라. (과도한 요청은 계정 차단 위험 — 소규모로만.)
utils/notifier.py는 Bark·Telegram·Webhook을 지원한다. 이를 참고해 Slack 또는 Discord 웹훅 알림을 추가하고, test_notifier_redactor_property.py 스타일로 "토큰이 로그에 노출되지 않는가"를 검증하는 테스트를 작성하라.
core/user_modes/base_strategy.py를 상속해 새 수집 전략(예: 특정 해시태그·검색 결과 기반)을 구현하고 user_mode_registry.py에 등록. 기존 다운로더 본체를 건드리지 않고 기능이 붙는 "개방-폐쇄 원칙(OCP)"을 몸으로 체감하라.
이 레포를 발판 삼아 어디로 뻗어갈까 — 4주 코스.
| 주차 | 주제 | 학습 내용 · 연결점 |
|---|---|---|
| 1주차 | 파이썬 비동기 기초 | coroutine · event loop · async/await · asyncio.gather · Semaphore/Lock. → 이 레포의 core·control 재독 |
| 2주차 | HTTP·웹 스크래핑 원리 | 요청/응답 헤더·쿠키·User-Agent · 개발자도구 Network 탭으로 비공개 API 관찰 · robots.txt·이용약관의 의미 |
| 3주차 | 안티봇 & 리버스 엔지니어링 | 요청 서명·브라우저 지문·CAPTCHA · Playwright로 헤드리스 브라우저 제어 · 왜 서명 알고리즘을 포팅하나 |
| 4주차 | 견고성 & 아키텍처 | 지수 백오프·서킷브레이커·idempotency · 팩토리/전략/DI 패턴 · pytest + hypothesis 속성기반 테스트 작성 |
배운 걸 종합해 "내가 좋아하는 합법적 공개 API(예: GitHub·Reddit·공공데이터)용 견고한 수집기"를 만들어보라. 이 레포의 control·storage 계층을 뼈대로 재사용하면, 대상만 바꿔 곧바로 프로덕션급 클라이언트가 나온다.
이 문서에 나온 용어 총정리.
| 용어 | 뜻 |
|---|---|
| 스크래핑 | 웹사이트/앱의 데이터를 프로그램으로 자동 수집하는 것 |
| 비공개 API | 앱 내부에서만 쓰라고 만든, 문서화되지 않은 서버 엔드포인트 |
| X-Bogus / A-Bogus | 더우인이 요청 진위를 검증하려 요구하는 서명 파라미터. 이 레포가 파이썬으로 재현 |
| ms_token | 세션마다 발급되는 보안 토큰. 만료되면 요청 거절 → 자동 갱신 필요 |
| 워터마크 제거 | 서버가 붙이기 전의 원본(무워터마크) 소스를 선택해 받는 것 |
| Rate limiting | 초당 요청 수 제한. 서버 과부하·차단 방지 + 봇 탐지 회피용 지터 동반 |
| 지수 백오프 | 재시도 간격을 1→2→5초처럼 점점 늘리는 전략 |
| 지터(jitter) | 대기 시간에 섞는 무작위 값. 요청이 기계처럼 규칙적이지 않게 |
| 팩토리 패턴 | 입력에 따라 알맞은 객체를 대신 만들어주는 설계 |
| 전략 패턴 | 교체 가능한 알고리즘(모드)을 클래스로 캡슐화해 갈아끼우는 설계 |
| DI(의존성 주입) | 필요한 객체를 밖에서 만들어 넣어주는 것 — 테스트·재사용에 유리 |
| 속성 기반 테스트 | 수천 개 무작위 입력으로 "항상 성립해야 할 규칙"을 검증(hypothesis) |
| 브라우저 폴백 | API가 막히면 실제 브라우저(Playwright)를 띄워 우회/CAPTCHA 대응 |
| 증분 다운로드 | 이미 받은 것은 건너뛰고 새 것만 받기(SQLite 이력 기반) |
| AGENTS.md | AI 코딩 에이전트가 해당 폴더 작업 시 지켜야 할 규칙 문서 |
2026-10-29 재검토: 공식 소스 커밋 ed863617aa52353821aca8fdebcfad4e916ebff5. 아래 초판 기록과 현재 소스 개정 내용을 구분한다.
더 파고들 곳.