네이버 블로그·카페의 글을 복사하여 로컬에 보관한다. 각 글의 제목·작성 날짜·내용을 txt 파일로 저장한다. 본 명세의 1~8장은 블로그를 기준으로 하며, 카페 확장은 9장에서 다룬다(블로그와 같은 파이프라인을 공유한다).
- 네이버 모바일 블로그. 예:
https://m.blog.naver.com/winter9377 - 인자로 블로그 아이디 또는 URL을 받는다. 블로그 아이디(
winter9377), 블로그 URL, 포스트 URL,PostView.naver?blogId=...형태 모두에서 아이디를 추출하며, 인식하지 못하면 명확한 오류로 중단한다.
헤드리스 브라우저는 쓰지 않는다. 본문이 정적 HTML에 들어 있어 HTTP 요청 + HTML 파싱만으로 충분하다.
- 모바일
post-listJSON API를 사용한다.GET https://m.blog.naver.com/api/blogs/{blogId}/post-list?categoryNo=0&itemCount=30&page=N - 응답의
totalCount는 항상 0으로 신뢰할 수 없다. 따라서items가 빈 페이지를 만날 때까지 페이지를 증가시킨다. - API가 같은 페이지를 반복 반환하는 비정상 상황을 대비해, 새로운
logNo가 하나도 없으면 종료한다. - 각 항목에서
logNo,titleWithInspectMessage(제목),addDate(작성 시각, epoch ms),thisDayPostInfo(빈 글 판정용)를 읽는다.
- API는 최신→과거 순으로 돌려준다.
- 명세 요구사항(과거→최근)에 맞추기 위해 전량 수집 후 뒤집어 과거→최근으로
정렬하고,
0001부터 순번을 매긴다.
GET https://m.blog.naver.com/PostView.naver?blogId={blogId}&logNo={logNo}- HTML의
se-main-container(스마트에디터 본문)를 파싱한다. - 본문 끝 표식인
#ad-bottom-portal직전까지만 본문으로 취급한다. - 컴포넌트가 레이아웃 div로 감싸인 경우에도 누락되지 않도록 깊이 우선으로
최상위
se-component를 순서대로 수집한다. - 구버전 폴백:
se-main-container가 없는 옛 글(스마트에디터 SE 3.0, 주로 외부 글을 가져온[공유]글)은se3_view로 폴백한다. 본문이 여러se_doc_viewer블록에 나뉘고 클래스가 언더스코어식(se_paragraph·se_oglink등)이며, 글 제목(se_documentTitle)은 본문에서 제외한다. 둘 다 없을 때만ParseError로 본다.
"N년 전 오늘 / 그날의 추억" 자동 노출 글은 실제 본문이 없으므로 저장하지 않는다. 요소 개수 같은 임계값(매직넘버)에 의존하지 않고, 네이버 데이터 모델의 의미 신호 두 가지로 판별한다.
- API 선필터:
post-list의thisDayPostInfo가 채워져 있으면 자동 노출 글이다. 본문을 받기 전에 거른다(네트워크 절약). - 본문 판정:
se-main-container에 실제 콘텐츠 모듈(텍스트·이미지·링크 카드·인용구·동영상·표·코드 등)이 하나도 없고se-anniversarySection만 있으면 빈 글로 본다.
근거: 기준 글(
224291762552,224291762419)을 분석한 결과 본문 모듈이se-anniversarySection단독이었고, 텍스트 길이는 255/243자로 단순 글자 수 임계값으로는 정상 글(최소 ~378자)과 구분되지 않았다. 정상 글은se-text,se-image,se-oglink,se-quotation등 여러 모듈을 포함한다.
-
글 1개 → 파일 1개.
-
파일명:
0001_<YYYY-MM-DD>_<제목>_<logNo>.txt- 순번 점두 번호로 과거→최근 순서를 보존한다.
- 날짜는 작성 시각을 KST로 변환한
YYYY-MM-DD. - 제목의 파일명 불가 문자는 치환하고, UTF-8 바이트 한계를 넘지 않게 자른다.
- 끝의
logNo는 글의 고유 식별자로, 증분 저장 시 위치가 아닌 글 ID로 재개 판정하는 근거가 된다.
-
파일 내용 형식:
제목: <제목> 날짜: <YYYY-MM-DD> 주소: <글 URL> ============================================================ <본문>
텍스트 외 요소도 순서·맥락을 보존하기 위해 플레이스홀더로 표기한다.
| 요소 | 표기 |
|---|---|
| 텍스트 | 그대로 (제로 폭 공백·nbsp 정리) |
| 이미지 | [이미지: <URL>] |
| 링크 카드 | [링크: <제목> <URL>] |
| 인용구 | > 들여쓰기 |
| 동영상 | [동영상: <URL>] 또는 [동영상] |
| 구분선 | ────────── |
- 재개(증분 저장): 파일명 끝의
logNo로 글을 식별해, 이미 받은 글은 다시 받지 않는다(본문 재요청 없음). 글이 삭제돼 순번이 밀려도 위치가 아닌 글 ID로 판정하므로 어긋나지 않으며, 어긋난 파일명은 현재 순번으로 이름을 맞춰 정렬을 유지한다.--force로 전체를 다시 받을 수 있다. - 중단 안전(Ctrl-C): 글은 임시 파일에 쓴 뒤 원자적으로 교체해 중단돼도 잘린 파일이 남지 않는다. 중단 시 진행분 실패 기록을 저장하고 깔끔히 종료하며, 재실행 시 받은 글을 건너뛰고 이어서 진행한다.
- 예의/안정성: 요청 사이 대기(기본 0.5초), 실패 시 지수 백오프 재시도. 재시도가 의미 없는 4xx(429 제외)는 즉시 중단한다.
- 실패 처리/재시도: 스크랩 글 등에서 서버가 본문(
se-main-container) 없는 응답을 간헐적으로 주는 경우가 있다. 글 단위로 몇 번 다시 받아 회복을 시도하고, 그래도 실패하면output/.failures.json에 logNo·제목·글 주소·오류·시각·시도 횟수를 기록한다. 다음 실행 때 이전 실패 글을 다시 시도할지 대화형으로 묻고 (--retry-failed/--no-retry-failed로 강제, 비대화형은 건너뜀), 재시도에 성공하면 기록을 제거한다. 저장 안 된 실패 글만 대상이며, 이미 저장된 글은 재시도 대상이 아니다. - 오류 처리: 예외를 조용히 무시하지 않는다. 글 한 건의 실패는 기록하고 요약에 보고하되 전체 작업은 계속한다. 응답 구조 변화 등 회복 불가 상황은 원인이 분명한 예외로 중단한다.
- 인터페이스: 실행 중 진행바와 최근 처리 결과 로그를 함께 갱신해 보여준다.
- 로깅: 진행 화면(rich Live)과 충돌하지 않도록 콘솔이 아닌 회전 파일
(
logs/naver-post-crawler.log)에 기록한다. 요청 재시도, 본문 재요청, 실패, 목록 수집·종료 요약을 남기며, 레벨(--log-level)과 위치(--log-dir)를 조정할 수 있다.
CLI와 동일한 코어(Crawler 제너레이터, FailureStore, resolve_blog_id)를
재사용하는 Flet 기반 Windows 데스크톱 앱. 크롤링은 백그라운드 스레드에서 돌리고
진행바·집계·최근 결과 로그를 실시간 갱신한다. 갱신은 이벤트 기반이다: 결과 로그·진행바·
집계는 글 한 건이 끝날 때마다 즉시 반영하고, 빠르게 바뀌는 상태 텍스트(목록 수집 카운트
등)는 백그라운드 스레드가 값만 기록한 뒤 0.2초 렌더 틱이 최신 값 하나로 합쳐(coalescing)
반영해 화면이 밀리지 않게 한다. 블로그 입력, 시작/중단, 출력 폴더 선택·열기, 이전 실패
재시도 체크박스, 고급 옵션(딜레이·재시도·force·로그 레벨)을 제공하며, 실행 중에는 입력·
출력 폴더 선택·찾아보기 버튼을 잠근다. Flet은 기본 의존성이며(uv가 명령마다 환경을 재동기화해도 설치/삭제가
반복되지 않도록 extra가 아닌 기본 의존성으로 둔다), 개발은 uv run naver-post-crawler-gui,
네이티브 빌드는 scripts/build.py(실행 OS 자동 감지)로 한다.
- Python (uv / pyproject 기반)
httpx(HTTP),selectolax(HTML 파싱),click(CLI),rich(진행 표시)flet(GUI)
입력이 카페 주소(cafe.naver.com/...)면 카페 모드로 동작한다. 블로그와 같은
수집·정렬·저장·재개·실패 처리 파이프라인(Crawler)을 공유하고, 소스 클라이언트와
본문 파서만 바꿔 끼운다. 소스 공통 계약은 PostSource 프로토콜
(iter_post_meta / fetch_post_html / post_url)로 정의한다.
- 다음 형태에서 clubId(=cafeId)·menuId·articleId·클럽 URL을 뽑는다.
- 클럽 URL:
cafe.naver.com/mycafe - 클럽 URL + 글:
cafe.naver.com/mycafe/12345 - 신형 SPA:
cafe.naver.com/ca-fe/cafes/{cafeId}/menus/{menuId},.../cafes/{cafeId}/articles/{articleId}?menuid=... - 구형 링크:
ArticleList.nhn?search.clubid=...&search.menuid=...
- 클럽 URL:
- 숫자 clubId가 없고 클럽 URL만 있으면, 카페 홈 HTML의
g_sClubId를 파싱해 해석한다.
공식 오픈 API에는 카페 글 "읽기"가 없어(가입·글쓰기 전용), 카페 웹 SPA가 쓰는
비공식 내부 API(apis.naver.com/cafe-web/...)를 사용한다.
- 대상 범위: 미지정 시 **전체글(menuId 0)**을 대상으로 한다. 게시판 목록을
따로 조회하지 않아도 카페 전체 글(읽기 권한 내)을 최신순으로 준다.
--menu로 특정 게시판만 지정할 수도 있다. (구형cafe2/SideMenuList는 현재 무효라 쓰지 않는다.) - 게시글 목록:
cafe-boardlist-api/v1/cafes/{cafeId}/menus/{menuId}/articles를 페이지네이션하며 articleId·제목·작성 시각(writeDateTimestamp)을 모은다. 빈 페이지 또는 새 글이 없으면 종료한다(블로그와 동일한 안전장치). 각 글 항목에 담긴 그 글의 실제 menuId를 함께 기록해 본문 조회에 쓴다(전체글 조회 시 글마다 게시판이 다르다). - 본문:
cafe-articleapi/v3/cafes/{cafeId}/articles/{articleId}의contentHtml. 카페 본문도 대부분 스마트에디터라 블로그의se-main-container/se3_view파서를 재사용하고, 스마트에디터가 아닌 단순 HTML은 텍스트·이미지만 보존하는 평문 폴백으로 처리한다(parse_cafe_body). 블로그 파서와 달리 컨테이너가 없어도 예외를 던지지 않으며, 응답 자체가 비면 상위(본문 조회)에서 재시도한다. - 응답 봉투는
message.result/result/ 최상위 등 여러 형태를 방어적으로 벗겨 처리한다.
⚠️ 내부 API는 비공식·비문서화라 경로·파라미터·응답 구조가 예고 없이 바뀔 수 있어 라이브 검증·유지보수가 필요하다. 단위 테스트는httpx.MockTransport로 파싱·흐름 로직을 검증한다.
- 등급 제한·비공개 게시판은 유효한 세션 쿠키(
NID_AUT/NID_SES)가 있어야 본문을 받을 수 있다. 자동 로그인은 약관 금지·캡차·기기 인증으로 막혀 있어 시도하지 않는다. 브라우저에서 로그인한 세션의 쿠키를 주입한다. - 쿠키 주입 경로: ① 문자열(
--cookie/ GUI 쿠키 칸), ② 브라우저 확장으로 내보낸 쿠키 파일(--cookie-file/ GUI "쿠키 업데이트" 버튼).cookie모듈이 Netscapecookies.txt와 JSON을 파싱해 naver 쿠키만 골라 헤더 문자열로 만든다. GUI "쿠키 업데이트"는 이 문자열을 앱 내부 저장소에 저장하고 이후 자동 사용한다. 출처 우선순위는 문자열 → 파일 → 저장된 쿠키. - 쿠키 없이/만료된 쿠키로 접근해 응답이 로그인·권한 오류면
LoginRequired로 안내한다. 카페 API는 상태 코드만으로 원인을 구분할 수 없어(성인인증 미완료·로그인 필요가 모두400) 응답 본문의 오류 봉투(errorCode/reason/message)를 함께 읽는다. 봉투 위치는 API마다 달라(목록 API는 최상위error, 글 API는result안) 둘 다 벗긴다.- 성인인증(
ADULTAUTH_REQUIRED)은 로그인만으로 풀리지 않으므로 "성인인증을 마친 계정으로 로그인하라"고 따로 안내한다. - 인증과 무관한 4xx 거절(예: 삭제된 글)은 네이버가 준 사유를 실은
CafeApiError로 보고한다. 어느 쪽이든 재시도해도 결과가 같으므로 즉시 중단한다(429는 제외).
- 성인인증(
- 쿠키는 로그인 세션 그 자체이므로 로그에 남기지 않고, 저장 시 소유자 전용 권한을 시도한다.
- 저장 형식은 블로그와 동일하다. 파일명 끝의 식별자는 카페에서는 articleId다
(
0001_<날짜>_<제목>_<articleId>.txt). 증분 재개·실패 기록도 이 값으로 판정한다.
- 카페 내부 API 사용, 비공개/등급 제한 영역의 자동 수집은 이용약관 위반 소지가 있고, robots.txt 위반이 문제된 판례가 있다. 요청 속도 제한·봇 차단 대상이 될 수 있다. 본인이 가입한 카페의 접근 가능한 글의 개인 백업만 전제한다.
- 빌드:
scripts/build.py→flet build <target>로 네이티브 앱을 만들고 Velopack(vpk pack)으로 설치기/업데이트 패키지를 만든다. 산출물은dist/velopack/이며 Windows는*-Setup.exe, macOS는*-Setup.pkg와 각각의*.nupkg·releases.<채널>.json이다. 빌드 머신은 타깃 OS와 같아야 한다(flet build·vpk양쪽 제약). - 채널: Windows는
win, macOS는osx. 채널이 피드 파일 이름을 정하므로 두 플랫폼 산출물을 같은 릴리스 태그에 함께 올려도 파일명이 겹치지 않는다. 앱의GithubSource는 최근 릴리스들을 훑어 자기 채널 피드만 읽으므로 태그를 나누면 그 조회 창을 두 배로 쓴다. - 배포:
scripts/deploy.py→ 버전 게이트(pyproject.toml의[project].version이 SSoT, 이전 릴리스와 같으면 중단) → 빌드 →vpk upload github --merge --tag v<version>. 릴리스 노트는 사람이 작성하며, 이미 같은 태그의 릴리스가 있으면(두 번째 플랫폼) 노트를 넘기지 않는다. - 자체 업데이트:
src/naver_post_crawler/velopack_update.py가 velopack 바인딩을 감싼다. GUI가 시작 시 워커 스레드에서run_startup_maintenance()(오래된 nupkg 정리)를 먼저 부르고 이어서 업데이트를 확인한다. velopack은 네이티브 모듈이라 import만으로 0.5초 이상 걸리므로 반드시 함수 안에서 지연 임포트하고 워커 스레드에서만 호출한다. - 설치/업데이트 라이프사이클 훅(
--veloapp-*)은 파이썬이 아니라 네이티브 러너 진입점에서 처리한다(scripts/flet_template.py가 Windows 러너main.cpp를 패치한다). flet이 만드는 Flutter 러너는 명령행 인자가 하나라도 있으면 "개발자 모드"로 간주해 파이썬을 실행조차 하지 않기 때문이다. 같은 이유로 쿠키 로그인 헬퍼도 argv가 아니라 환경변수로 기동한다(cookie_login). - 서명: 이번 범위에서는 하지 않는다(미서명 배포).
NPC_SIGN_*환경변수를 채우면scripts/sign.py가 플랫폼별 인자를 만들어 붙인다. macOS 사용자는 Gatekeeper 경고를 수동으로 넘겨야 하며 README에 절차를 안내한다. - 앱 데이터 경로: Velopack은 업데이트할 때 설치 폴더(Windows
current\, macOS.app번들)를 통째로 교체한다. 따라서 쿠키·설정·로그는 실행 파일 옆이 아니라cookie.app_data_dir()아래에 둔다. GUI 출력 폴더 기본값은 사용자 문서 폴더 아래이며 마지막 선택을 기억한다(CLI는 cwd 상대 유지).