코드를 읽기 전에 이 문서를 먼저 읽으면 전체 구조를 파악할 수 있음. 룰 추가/수정 시 반드시 이 문서도 업데이트할 것.
┌─────────────────────────────────────────────────────────────────────┐
│ Content Script (content.ts) │
│ │
│ 사용자 클릭 ──┐ │
│ Observer ─────┼──→ startTranslation() ──→ translatePage() │
│ autoTranslate ┘ │ │ │
│ circuit breaker detectTextBlocks() │
│ (15회/분 제한) ┌────┴────┐ │
│ Phase 0 Phase 1 Phase 2 │
│ │ │ │ │
│ └────┬───┘ │ │
│ 필터 체인 ◄─────────┘ │
│ │ │
│ processBatch() × N │
│ │ │
│ chrome.runtime.sendMessage │
└────────────────────────────────────────────────┼───────────────────┘
│
┌────────────────────────────────────────────────┼───────────────────┐
│ Background Service Worker (background.ts) │ │
│ ▼ │
│ rate limiter │
│ (50콜/분 제한) │
│ │ │
│ ┌───────────┴──────────┐ │
│ │ 캐시 체크 │ │
│ │ hit → 즉시 반환 │ │
│ │ miss → API 호출 │ │
│ └───────────┬──────────┘ │
│ │ │
│ ┌───────────┴──────────┐ │
│ │ Gemini │ OpenAI │ .. │ │
│ └───────────┬──────────┘ │
│ │ │
│ 응답 + 캐시 저장 │
└────────────────────────────────────────────────┼───────────────────┘
│
┌────────────────────────────────────────────────┼───────────────────┐
│ Content Script (translator.ts) │ │
│ ▼ │
│ injectTranslation() │
│ ┌─────┬─────┬─────┐ │
│ 경로1 경로2 경로2.5 경로3 │
│ 네비 형제 강제 블록 │
│ 인라인 삽입 교체 내부 │
└─────────────────────────────────────────────────────────────────────┘
document.body
│
│ 사이트 룰에 translateSelectors 있나?
│
├─ YES → Phase 0: 커스텀 셀렉터만 사용
│ (Phase 1, 2 건너뜀)
│
│ ┌─────────────────────────────────┐
│ │ Gmail 예: │
│ │ querySelectorAll('.bqe, .y2') │
│ │ → 이메일 제목/미리보기만 정확 감지 │
│ └─────────────────────────────────┘
│
└─ NO → Phase 1 + Phase 2 순차 실행
│
├─ Phase 1: 시맨틱 블록 태그
│
│ 대상 태그 (TRANSLATABLE_TAGS):
│ ┌──────────────────────────────────────────┐
│ │ P H1 H2 H3 H4 H5 H6 LI TD TH │
│ │ BLOCKQUOTE FIGCAPTION DT DD │
│ └──────────────────────────────────────────┘
│
│ 텍스트 추출: getDirectText()
│ ┌──────────────────────────────────────┐
│ │ <p>Hello <a>world</a></p> │
│ │ ↓ │
│ │ "Hello world" (인라인 마크업 포함) │
│ │ │
│ │ <p>Intro <div>Nested</div></p> │
│ │ ↓ │
│ │ "Intro" (자식 블록 태그 제외) │
│ └──────────────────────────────────────┘
│
└─ Phase 2: 텍스트 컨테이너 (보완)
Phase 1이 놓치는 네비, 사이드바, 바이오 등
대상: DIV, SPAN, A 중
┌──────────────────────────────────────┐
│ 자식이 없음 (리프 노드) │
│ OR │
│ 자식이 전부 인라인 (A, SPAN, STRONG..) │
└──────────────────────────────────────┘
감지된 텍스트 중 번역 불필요한 것을 걸러냄. 저비용 순서로 실행 (early exit).
감지된 텍스트
│
├─ F1: URL인가? ──────────── YES → 스킵 "github.com/user/repo"
│
├─ F2: 영어인가? ─────────── NO → 스킵 "한국어 텍스트입니다"
│ (ASCII 문자 < 60%)
│
├─ F3: 스킵 조상 안인가? ─── YES → 스킵 <nav role="menu"> 내부
│ (Phase 1 only) SCRIPT, STYLE, CODE, PRE 내부
│
├─ F4: 링크 과다? ────────── YES → 스킵 텍스트의 70%+가 <a> 안
│ (Phase 1, H1-H6/LI 제외)
│
├─ F5: 이미 감지? ────────── YES → 스킵 Phase 1 블록을 감싸는 div
│ (Phase 2 only)
│
├─ F6: 짧은 셀? ──────────── YES → 스킵 TD/TH < 20자 (날짜, 숫자)
│ (Phase 1, TD/TH only)
│
└─ F7: 테이블 서브트리? ──── YES → 스킵 TABLE 전체 건너뜀
(Phase 2 only)
시각 예시는 ui-guide.md 에서 before/after로 확인.
injectTranslation(element, translatedText)
│
│ LI 또는 A(LI 안) 이고 ≤60자?
├─ YES → 경로 1: 네비 인라인
│ label 스팬 찾아서 안에 삽입
│ class: b3rys-translation-inline
│
│ 사이트 룰: injectAsSibling + inline 요소?
├─ YES → 경로 2: 형제 삽입
│ element.after(translationSpan)
│ class: b3rys-translation
│
│ 사이트 룰: forceReplace?
├─ YES → 경로 2.5: 강제 교체
│ markOriginalContent() + 번역 추가
│ CSS로 parallel/replace 전환
│ class: b3rys-translation
│
└─ NO → 경로 3: 블록 내부 (기본)
markOriginalContent() + 요소 안에 번역 추가
≤60자: b3rys-translation-inline
>60자/제목: b3rys-translation
Before:
<p> "Hello " <a>world</a> </p>
After markOriginalContent():
<p>
<span data-b3rys-original>"Hello "</span> ← 텍스트 노드 래핑
<a data-b3rys-original>world</a> ← 기존 요소에 속성 추가
<span data-b3rys-translated>번역문</span> ← 번역 삽입
</p>
Replace 모드: CSS가 [data-b3rys-original] { display: none }
Parallel 모드: 둘 다 표시
┌─────────────────────────────────────────────────────────────────┐
│ site-rules.ts │
│ │
│ mail.google.com │
│ ├─ translateSelectors: ['.bqe', '.y2'] │
│ │ → 이메일 제목/미리보기 스팬만 감지 (표준 감지가 Gmail에서 실패) │
│ ├─ forceReplace: true │
│ │ → 원문 마킹 + 번역 추가 (CSS 모드 전환 가능) │
│ └─ mainContentSelector: '[role="main"]' │
│ → viewport 우선순위 영역 지정 │
│ │
│ substack.com │
│ ├─ injectAsSibling: true │
│ │ → 인라인 요소 뒤에 형제로 삽입 (내부 삽입 시 레이아웃 깨짐) │
│ └─ mainContentSelector: '.post-content, .body-SxXE9l, article' │
│ → 본문 영역 특정 │
└─────────────────────────────────────────────────────────────────┘
1. site-rules.ts 에 규칙 추가
2. tests/fixtures/ 에 해당 사이트 HTML 스니펫 저장
3. 테스트 작성 (감지 + 주입 + 라운드트립)
4. docs/pipeline.md 사이트 규칙 섹션 업데이트
5. npm run test → npm run lint → npm run build
┌─────────────────────────────────────────────────┐
│ MutationObserver │
│ 감시: document.body (childList + subtree) │
│ │
│ 새 노드 추가됨 │
│ │ │
│ ├─ 텍스트 노드? ────────── 무시 │
│ ├─ data-b3rys-* 속성? ──── 무시 (자체 변경) │
│ ├─ b3rys-* 클래스? ─────── 무시 (자체 변경) │
│ └─ 일반 HTMLElement? ───── hasNewContent = true │
│ │
│ hasNewContent? │
│ │ │
│ └─ YES → 디바운스 500ms → onNewContent() │
│ │ │
│ ├─ state=done → startTranslation() │
│ │ (증분: 기존 BLOCK_ID 보존) │
│ │ │
│ └─ state=loading → pendingRestart │
│ + cancelTranslation() │
└─────────────────────────────────────────────────┘
⚠️ 절대 하면 안 되는 것:
Observer 콜백에서 removeAllTranslations() 호출
→ BLOCK_ID 전부 제거 → 전체 재감지 → 무한 루프
detectTextBlocks() 결과
│
├─ viewport 안 블록 ──→ main 영역 / side 영역 분리
│ │ │
│ │ ├─ Phase 1a: main 영역 (최우선)
│ │ │ VIEWPORT_BATCH_SIZE(5)개씩 병렬
│ │ │
│ │ └─ Phase 1b: side 영역
│ │ VIEWPORT_BATCH_SIZE(5)개씩 병렬
│ │
└─ viewport 밖 블록 ──→ 거리순 정렬
│
└─ Phase 2: BATCH_SIZE(15)개씩
PARALLEL_BATCH_COUNT(3)그룹 동시
사용자 체감:
화면에 보이는 본문 → 사이드바 → 스크롤 아래 순서로 번역 도착