# ThreadAuto 기능 전수 조사 (Feature Registry)

> 기준 커밋: `beaaff8` — `[task-367.1] MoviePy/FFmpeg 기반 영상 슬라이드쇼 모듈 구현 (Phase 1)`
> 조사 일시: 2026-03-07
> 대상 경로: `/home/jay/projects/ThreadAuto/`
> 제외 항목: `.worktrees/`, `__pycache__/`, `venv/`, `node_modules/`

---

## 목차

1. [프로젝트 개요](#1-프로젝트-개요)
2. [모듈별 기능 목록](#2-모듈별-기능-목록)
   - 2.1 [루트 진입점](#21-루트-진입점)
   - 2.2 [content/ — 콘텐츠 생성](#22-content--콘텐츠-생성)
   - 2.3 [renderer/ — 이미지 렌더링](#23-renderer--이미지-렌더링)
   - 2.4 [publisher/ — Threads 발행](#24-publisher--threads-발행)
   - 2.5 [crawler/ — 데이터 수집](#25-crawler--데이터-수집)
   - 2.6 [scheduler/ — 자동 스케줄링](#26-scheduler--자동-스케줄링)
   - 2.7 [auth/ — 인증·토큰 관리](#27-auth--인증토큰-관리)
   - 2.8 [api/ — Threads API 클라이언트](#28-api--threads-api-클라이언트)
   - 2.9 [monitor/ — 상태 모니터링](#29-monitor--상태-모니터링)
   - 2.10 [storage/ — 데이터 저장](#210-storage--데이터-저장)
   - 2.11 [pipeline/ — 레거시 파이프라인](#211-pipeline--레거시-파이프라인)
   - 2.12 [web/ — 웹 대시보드](#212-web--웹-대시보드)
   - 2.13 [server/ — FastAPI 이미지 서빙](#213-server--fastapi-이미지-서빙)
   - 2.14 [video/ — 영상 생성 (Phase 1)](#214-video--영상-생성-phase-1)
3. [파이프라인 흐름 요약](#3-파이프라인-흐름-요약)
4. [외부 연동 목록](#4-외부-연동-목록)
5. [주요 설정·데이터 파일](#5-주요-설정데이터-파일)
6. [미구현·TODO 항목](#6-미구현todo-항목)
7. [테스트 파일 목록](#7-테스트-파일-목록)

---

## 1. 프로젝트 개요

ThreadAuto는 보험 GA(인카다이렉트 TOP사업단 / 서울대보험쌤) 채용 마케팅을 위한 **Threads 자동 포스팅 시스템**이다.
Claude CLI를 통해 카드뉴스 텍스트를 AI 생성하고, Pillow로 이미지를 렌더링하여, Tailscale Funnel을 통해 공개 URL을 노출한 뒤 Threads Graph API로 carousel 게시물을 발행한다.

- **FastAPI** 웹 서버 (포트 8200) + APScheduler 자동화
- **두 가지 파이프라인**: v2(신규, daily_runner/run_full_pipeline) + 레거시(orchestrator.py)
- **브랜드명**: 인카다이렉트 TOP사업단 / 서울대보험쌤 / 서울대보험크루
- **이미지 크기**: 1080×1350 px (4:5 비율, Threads 최적)
- **영상 크기**: 1080×1920 px (9:16 세로 Reels 형식)

---

## 2. 모듈별 기능 목록

### 2.1 루트 진입점

#### `config.py`

| 항목 | 내용 |
|------|------|
| 환경변수 로드 | `.env.keys` 파일을 subprocess로 source하여 `THREADS_APP_ID`, `THREADS_APP_SECRET`, `REDIRECT_URI`, `SCOPES` 취득 |
| `TOKEN_DIR` | `.tokens/` 디렉토리 경로 상수 |
| `AUTO_POST_DISCLAIMER` | `"서울대보험크루에서 제작한 AI프로그램이 자동으로 게시하였습니다."` — 모든 캡션 말미에 자동 삽입 |

#### `main.py` — FastAPI 서버 (v2.0.0, 포트 8200)

| 기능 | 설명 |
|------|------|
| `lifespan()` | 서버 시작 시 `CronRunner.start()` + `TokenRefresher.start()` 자동 구동; 종료 시 `stop()` |
| `GET /` | 서버 상태 HTML 페이지 반환 |
| `GET /auth/login` | Threads OAuth 인증 페이지 리디렉션 |
| `GET /auth/threads/callback` | 인증 코드 → 단기 토큰 → 장기 토큰 교환 후 저장; 완료 HTML 반환 |
| `GET /health` | 헬스체크 JSON (`{"status": "ok"}`) |
| 정적 파일 마운트 | `/web/static` → `static/` 디렉토리 |
| 라우터 포함 | `image_router`(server/), `web_router`(web/routes), `web_api_router`(web/api) |

#### `cli.py` — Typer CLI

| 커맨드 | 설명 |
|--------|------|
| `auth` | OAuth URL 출력 후 인증 코드 수동 입력 → 토큰 교환·저장 |
| `profile` | Threads 프로필 조회 출력 |
| `post-text` | 텍스트 게시물 즉시 발행 |
| `post-image` | 이미지 + 캡션 게시물 즉시 발행 |
| `render` | 카드뉴스 이미지 로컬 렌더링 (--type TypeA~TypeE, --title, --hook, --items, --cta, --theme) |
| `crawl-news` | RSS 크롤링 실행 + 캐시 갱신 |
| `generate` | ContentGeneratorV2로 콘텐츠 1건 생성 출력 |
| `pipeline` | 레거시 PipelineOrchestrator 단건 실행 (--template-type, --source-type) |
| `scheduler-start` | CronRunner 시작 (포그라운드 블로킹) |
| `scheduler-status` | 다음 실행 예정 시각 목록 출력 |
| `publish` | 로컬 이미지 파일로 carousel 즉시 발행 |
| `health` | HealthChecker.check_all() 결과 출력 |

#### `run_full_pipeline.py` — 수동 풀 파이프라인 스크립트

| 단계 | 내용 |
|------|------|
| Step 1 | `select_single_topic()` — 카테고리 가중 랜덤 토픽 1건 선택 |
| Step 2 | `ContentGeneratorV2.generate()` — Claude CLI로 슬라이드 콘텐츠 생성 |
| Step 3 | `CardNewsRenderer.render_from_slides(get_random_theme())` — 이미지 렌더링 |
| Step 4 | `ThreadsPublisher.publish_cardnews()` — Threads carousel 발행 |
| 검증 | `_build_caption()` 결과 출력 (면책 문구 포함 여부 확인) |

#### `run_pipeline_report.py` — 파이프라인 리포트 스크립트

| 기능 | 내용 |
|------|------|
| `check_ai_smell(text)` | "여러분은 어떻게 생각하세요?" 등 AI 냄새 표현 감지 패턴 검사 |
| `select_daily_topics()` | 하루 10건 토픽 전체 선택 |
| 리포트 출력 | 슬라이드 구조, 캡션 길이, 해시태그, AI 냄새 여부 출력 |

---

### 2.2 `content/` — 콘텐츠 생성

#### `topic_selector.py` — 소재 선택 엔진

| 상수/변수 | 값 |
|----------|----|
| `DAILY_MIX` | `{"고민공감": 3, "정보제공": 3, "사회적증거": 2, "업계동향": 1, "CTA": 1}` |
| `REUSE_COOLDOWN_DAYS` | 7일 |
| `TOPICS_PATH` | `content/evergreen_topics.json` |
| `NEWS_CACHE_PATH` | `content/news_cache.json` |
| `YOUTUBE_CACHE_PATH` | `content/youtube_cache.json` |

| 함수 | 설명 |
|------|------|
| `load_topics()` | `evergreen_topics.json` 로드; 파일 없거나 파싱 오류 시 빈 리스트 반환 |
| `save_topics(topics)` | 변경된 `used_count`/`last_used`를 `evergreen_topics.json`에 저장 |
| `_is_on_cooldown(topic)` | `last_used` 기준 7일 이내 사용 여부 판단 |
| `select_from_pool(topics, category, count)` | 쿨다운 제외 → `used_count` 오름차순 → 동점 내 셔플 → `count`개 선택; 선택 후 `used_count++`, `last_used` 갱신 |
| `_load_cache(path)` | JSON 캐시 파일 로드 |
| `_pick_latest_cache_item(items)` | `published_at` 또는 `fetched_at` 기준 최신 항목 반환 |
| `_cache_item_to_topic(item, source)` | RSS/YouTube 캐시 아이템 → topic dict 변환 (category="업계동향") |
| `select_trend_topic(topics)` | 업계동향 1건 선택: RSS 캐시 → YouTube 캐시 → 에버그린 fallback 순 |
| `select_single_topic(category=None)` | 토픽 1건 선택 (category=None이면 DAILY_MIX 가중 랜덤); used_count 갱신 후 저장 |
| `select_daily_topics()` | DAILY_MIX 비율로 10건 선택; 업계동향은 `select_trend_topic()` 처리 |

#### `content_generator_v2.py` — V2 AI 콘텐츠 생성기

| 클래스/메서드 | 설명 |
|--------------|------|
| `ContentGeneratorV2` | Claude CLI subprocess 기반 카드뉴스 콘텐츠 생성 클래스 |
| `generate(topic, context)` | 소재 1건 → 슬라이드 5~7장 + caption + hashtags=[] 반환; 실패 시 1회 재시도 |
| `_call_claude(system_prompt, user_prompt)` | `claude --print --model claude-sonnet-4-6 --system-prompt <system>` 호출; stdin으로 user_prompt 전달; timeout=120s; CLAUDECODE 환경변수 제거(nested session 방지) |
| `_parse_response(raw, topic)` | markdown 코드블록 JSON 우선 추출 → fallback 중괄호 기준; topic 메타 강제 병합 (topic_id, category, card_type); hashtags=[] 강제 |
| `_validate_structure(data)` | slides 5~7장, cover(첫 번째), cta(마지막), 중간 슬라이드 ≥2장 검증; 허용 타입: cover/card_list/detail/body/cta |

슬라이드 타입별 필드:

| type | 필수 필드 |
|------|-----------|
| `cover` | `hook` 또는 `title` 중 1개 이상; `keywords`(list, 선택) |
| `card_list` | `items`(list, 각 item에 `title`+`description`) |
| `detail` | `items`(list, 각 item에 `label`+`value`) |
| `body` | `title`+`description` (하위 호환) |
| `cta` | 신규: `title`+`cta_text`+`items`; 구버전: `text`+`contact` |

#### `generator.py` — 레거시 V1 콘텐츠 생성기

| 클래스/메서드 | 설명 |
|--------------|------|
| `ContentGenerator` | 레거시 Claude CLI 기반 생성기 |
| `generate(source_text, template_type)` | `claude -p <prompt>` 호출; timeout=60s; 2회 시도; TypeA~TypeE 템플릿 기반 |

#### `quality.py` — 콘텐츠 품질 검사

| 상수/클래스 | 설명 |
|------------|------|
| `FORBIDDEN_KEYWORDS` | 수익 보장, 원금보장, 사기 등 금지 키워드 목록 |

| 함수/클래스 | 설명 |
|------------|------|
| `check_forbidden(text)` | 금지 키워드 포함 여부 검사 → `(bool, list[str])` |
| `_jaccard(set_a, set_b)` | Jaccard 유사도 계산 (`|A∩B| / |A∪B|`) |
| `check_duplicate(content, history, threshold=0.5)` | Jaccard 0.5 이상이면 중복으로 판단 |
| `validate_content(content)` | slides 수=5, title/body 필드, 금지 키워드 통합 검증 → `(bool, list[str])` |
| `ContentHistory` | MAX_HISTORY=30, FIFO JSON 히스토리; `load()`, `save()`, `is_duplicate()` |

#### `prompts.py` — 레거시 V1 프롬프트

| 상수/함수 | 설명 |
|----------|------|
| `SYSTEM_PROMPT` | GA 보험 설계사 이직 유도 카피라이터 페르소나 |
| `TEMPLATE_PROMPTS` | TypeA(숫자훅), TypeB(뉴스카드), TypeC(비교카드), TypeD(성공스토리), TypeE(체크리스트) |
| `get_prompt(template_type, source_text)` | 시스템 + 유저 프롬프트 조합 반환 |

#### `prompts_v2.py` — V2 카테고리별 프롬프트

| 상수/함수 | 설명 |
|----------|------|
| 페르소나 | "서울대보험쌤" (서울대 출신 현직 보험 설계사) |
| `_TONE_GUIDES` | 카테고리별 톤 가이드 (고민공감/정보제공/사회적증거/업계동향/CTA) |
| `_SLIDE_GUIDES` | 카테고리별 슬라이드 구조 가이드 |
| `_load_fact_db()` | `content/fact_db.md` 로드 (lru_cache); 사실 검증용 레퍼런스 |
| `get_system_prompt(category)` | 카테고리에 맞는 시스템 프롬프트 생성 |
| `get_user_prompt(topic, context)` | topic dict + 업계동향 context → 유저 프롬프트; context는 RSS/YouTube 원문 |

#### `compliance_filter.py` — 규정 준수 필터

| 계층 | 내용 |
|------|------|
| Layer 1 | `BLACKLIST_PATTERNS` 7개 regex 검사 (수익률 보장, 경쟁사 비방, 기한 압박 등) |
| Layer 2 | 숫자 추출 → `fact_db.md` 교차 검증 (허용 수치 범위 이탈 시 플래그) |
| Layer 3 | 리스크 스코어링 → `risk_level`: low / medium / high |

| 함수 | 설명 |
|------|------|
| `_layer1_check(text)` | 블랙리스트 regex 매칭 |
| `_layer2_check(text)` | 숫자 추출 + fact_db 교차 검증 |
| `compliance_check(text)` | 3계층 통합 검사 → `{pass, flags, risk_level}` |
| `filter_content(content)` | high → 전체 폐기(None 반환), medium → 위반 문장 제거, low → 통과; `{content/None, check_result, removed_sentences}` |

#### `pipeline.py` — V2 일일 파이프라인

| 함수 | 설명 |
|------|------|
| `run_daily_pipeline()` | 6단계: `select_daily_topics()` → `crawl_rss()` → `crawl_youtube()` → `ContentGeneratorV2.generate()` per topic → `filter_content()` → `daily_queue/YYYY-MM-DD.json` 저장 |
| `_build_trend_context(topic)` | RSS: `"[뉴스 제목] {title}\n[요약] {summary}"`; YouTube: `"[영상 제목] {title}\n[자막] {transcript[:2000]}"` |

---

### 2.3 `renderer/` — 이미지 렌더링

#### `cardnews.py` — 카드뉴스 렌더러 (V2)

| 클래스/메서드 | 설명 |
|--------------|------|
| `CardNewsRenderer` | `BaseRenderer` 상속; 출력 디렉토리 `output/` |
| `render_from_slides(slides, theme)` | 슬라이드 타입별 dispatch → `list[Path]` 반환 |
| `render_cover(slide, theme, idx)` | 커버 슬라이드: bg_gradient 전면 + hook + title + 브랜딩 바 |
| `render_card_list(slide, theme, idx)` | 카드 리스트: 제목 헤더 + item 카드들 (title + description) |
| `render_detail(slide, theme, idx)` | 상세 리스트: label-value 쌍 테이블형 |
| `render_summary_cta(slide, theme, idx)` | CTA 슬라이드: 브랜드 강조 + cta_text + items + 연락처 |
| `render_body(slide, theme, idx)` | body 슬라이드 (하위 호환): 넘버 배지 + title + description |
| `render_all(title, hook_text, items, cta_text, theme)` | 레거시 렌더링 (cover + body×N + cta) |
| `_strip_emoji(text)` | 이모지 제거 후 렌더링 |
| 출력 파일명 | `output/{content_hash}_{idx:02d}.png` |

#### `themes.py` — 컬러 테마 시스템

| 항목 | 설명 |
|------|------|
| `Theme` (dataclass, frozen) | `name`, `primary`, `secondary`, `accent`, `bg_gradient(tuple)`, `text_primary`, `text_secondary`, `card_bg`, `number_bg`, `border_color` |

5종 테마:

| 테마명 | 특성 | 배경 그라데이션 |
|--------|------|----------------|
| `NAVY_GOLD` | 전문적·고급스러운 | `#0A1628` → `#1B3A6B` (다크 네이비) |
| `BLACK_RED` | 강렬·임팩트 | `#1A0000` → `#3D0000` (블랙-레드) |
| `GREEN_WHITE` | 신선·자연 (라이트 모드) | `#E8F5EE` → `#FFFFFF` |
| `PURPLE_PINK` | 트렌디·감각적 | `#2D0045` → `#6B0057` (다크 퍼플-마젠타) |
| `ORANGE_CREAM` | 따뜻·친근 (라이트 모드) | `#FFF3E0` → `#FFE0B2` |

| 함수/클래스 | 설명 |
|------------|------|
| `get_theme(name)` | 이름으로 테마 조회 (대소문자 무시 fallback) |
| `get_random_theme()` | 가중치 기반 랜덤 선택 (다크3×25%, 라이트2×12.5%); `data/theme_history.json`으로 직전 테마 연속 방지 |
| `get_next_theme()` | 순환 이터레이터로 순서대로 반환 |
| `LayoutPattern` (Enum) | `PATTERN_A`(좌측 넘버링), `PATTERN_B`(상단 아이콘), `PATTERN_C`(전면 배경+중앙 텍스트) |
| `get_slide_pattern(slide_index)` | 0-based 인덱스 → 레이아웃 패턴 매핑 (index 0, 6 → PATTERN_C; 나머지 → A/B 교번) |
| `BrandingBar` (dataclass, frozen) | `primary_label="인카다이렉트 TOP사업단"`, `secondary_label="서울대보험쌤"`, `full_text(separator)` |
| `DEFAULT_BRANDING` | 기본 BrandingBar 인스턴스 |
| `BRAND_COVER_LABEL` | `"서울대보험크루"` |

#### `engine.py` — 기반 렌더링 엔진

| 클래스/메서드 | 설명 |
|--------------|------|
| `BaseRenderer` | `WIDTH=1080`, `HEIGHT=1350` |
| `wrap_text(text, font, max_width)` | 한글 포함 텍스트 줄바꿈 |
| `fit_font_size(text, max_width, max_height, min_size, max_size)` | 영역에 맞게 폰트 크기 자동 조정 |
| `draw_text_block(draw, text, font, x, y, color, align)` | 텍스트 블록 렌더링 |
| `draw_rect(draw, box, fill, radius)` | 라운드 코너 사각형 |
| `draw_line(draw, start, end, fill, width)` | 구분선 |
| `draw_circle(draw, center, radius, fill)` | 원형 |
| `draw_header_bar(draw, theme)` | 상단 헤더 바 |
| `draw_footer(draw, theme, branding)` | 하단 브랜딩 바 |
| `draw_cta(draw, theme, cta_text)` | CTA 버튼 영역 |
| `save(image, filename)` | `output/{filename}` 저장 |

#### `fonts.py` — 폰트 관리

| 클래스/메서드 | 설명 |
|--------------|------|
| `FontManager` | Pretendard → NotoSansCJKkr 우선순위로 폰트 탐색 |
| `regular(size)` | 일반체 (lru_cache maxsize=32) |
| `bold(size)` | 굵은체 (lru_cache maxsize=32) |

#### `colors.py` — 브랜드 컬러

| 상수 | 값 |
|------|-----|
| `BRAND.PRIMARY` | `#003087` (인카다이렉트 코퍼레이트 블루) |
| `BRAND.ACCENT` | `#C9A84C` (웜 골드) |
| 기타 | 배경, 텍스트, 카드 배경 등 브랜드 컬러 팔레트 |

#### `templates.py` — 레거시 V1 템플릿 렌더러

| 클래스 | 설명 |
|--------|------|
| `TypeA` | 숫자훅: 대형 숫자 + 부제 + CTA 버튼 |
| `TypeB` | 뉴스카드: NEWS 라벨 + 헤드라인 + 본문 + 출처 |
| `TypeC` | 비교카드: 2컬럼 (현재 상황 vs TOP사업단) 체크마크/X 아이콘 |
| `TypeD` | 성공스토리: 인용 부호 + 인용문 + 이름/경력 |
| `TypeE` | 체크리스트: 제목 블록 + 체크박스 항목들 + 저장 유도 문구 |

---

### 2.4 `publisher/` — Threads 발행

#### `threads_publisher.py`

| 클래스/메서드 | 설명 |
|--------------|------|
| `ThreadsPublisher` | `ImageServer` + `FirestoreClient` 래퍼 |
| `__init__(image_base_url)` | ImageServer 초기화 + `ensure_server()` + FirestoreClient 초기화 |
| `publish(post_data)` | 단건 발행: image_path 있으면 `post_image()`, 없으면 `post_text()` |
| `publish_cardnews(title, items, theme_name, content)` | V2 파이프라인 카드뉴스 발행: render → URL 생성 → carousel 발행 → 이력 기록 |
| `_build_caption(content)` | `caption` + 랜딩페이지 URL + `AUTO_POST_DISCLAIMER` 조합 |
| `_extract_landing_url(content)` | `content["landing_url"]` 우선; CTA 슬라이드 텍스트에서 `incar-top1.tistory.com` 탐지 |
| `_get_access_token()` | `get_valid_token()` 호출 |
| `_run_publish(access_token, content, image_path)` | ThreadsClient 래핑 비동기 발행 |
| `_run_async(coro)` | 실행 중 루프 있으면 ThreadPoolExecutor, 없으면 `asyncio.run()` |
| `_get_image_url(image_path)` | 로컬 경로 → 공개 URL 변환 |
| `_record_history(threads_post_id, firestore_post_id, published_at)` | Firestore `ta_history` 컬렉션에 발행 이력 저장 |

#### `image_server.py` (publisher/)

| 클래스/메서드 | 설명 |
|--------------|------|
| `ImageServer` | Tailscale Funnel 기반 이미지 공개 URL 서버 |
| `base_url` | `"https://aidevserver.tail2cdab6.ts.net/images"` (기본값) |
| `ensure_server()` | HTTP 서버 + Tailscale Funnel 상태 확인 및 기동 |
| `_ensure_http_server()` | `python3 -m http.server 8080` 프로세스 실행 여부 확인 및 기동 |
| `_ensure_funnel()` | `tailscale funnel 8080` 활성화 |
| `get_public_url(local_path)` | `{base_url}/{filename}` 형태 URL 반환 |
| `get_public_urls(local_paths)` | 배치 URL 변환 |
| `verify_url(url)` | HEAD 요청으로 URL 접근 가능 여부 확인 |
| `get_default_server()` | 싱글턴 인스턴스 반환 |

---

### 2.5 `crawler/` — 데이터 수집

#### `rss_fetcher.py` — RSS 피드 소스

| 소스 | URL |
|------|-----|
| 다자비 | 보험 설계사 관련 RSS |
| 뉴스와이어 | 보험 업계 보도자료 |
| 이투데이 | 보험 뉴스 |
| 이데일리 | 금융·보험 |
| 한국경제 | 경제 일반 |
| 매일경제 | 경제 일반 |
| 파이낸셜뉴스 | 금융 뉴스 |

| 함수 | 설명 |
|------|------|
| `fetch_feed(url)` | feedparser로 단일 RSS 피드 파싱 |
| `fetch_all_feeds()` | 7개 피드 전체 병합 반환 |

#### `rss_crawler.py`

| 함수 | 설명 |
|------|------|
| `crawl_rss()` | `fetch_all_feeds()` → `filter_articles(min_score=1)` → 필터링된 기사 반환 |
| `load_cache()` | `content/news_cache.json` 로드 |
| `save_cache(items)` | 7일 TTL 적용, URL 기준 중복 제거 후 저장 |
| `update_cache(new_items)` | 기존 캐시 + 신규 항목 병합 후 `save_cache()` |

#### `youtube_crawler.py`

| 상수 | 값 |
|------|-----|
| 채널 URL | `https://www.youtube.com/@insjournal/videos` |

| 함수 | 설명 |
|------|------|
| `get_recent_videos(max_count=10)` | yt-dlp `--flat-playlist --dump-json` 으로 최신 영상 목록 취득 |
| `get_transcript(video_id)` | youtube_transcript_api: ko 수동자막 → ko 자동자막 → en 순 fallback |
| `crawl_youtube()` | 최신 영상 자막 취득; 실패 시 title+description fallback |
| `load_cache()` | `content/youtube_cache.json` 로드 |
| `save_cache(items)` | video_id 기준 중복 제거 후 저장 |
| `update_cache(new_items)` | 캐시 병합 업데이트 |

#### `keyword_filter.py` — 키워드 필터

3계층 키워드 시스템:

| 계층 | 점수 | 예시 키워드 |
|------|------|------------|
| `PRIMARY_KEYWORDS` | 3점 | 정착지원금, 스카우트, 영입, GA이직, 리크루팅 |
| `SECONDARY_KEYWORDS` | 2점 | 수수료상한제, GA성장, 제판분리 |
| `TERTIARY_KEYWORDS` | 1점 | 수당체계, 성과급, 설계사연봉 |

| 함수 | 설명 |
|------|------|
| `match_keywords(text)` | 텍스트에서 매칭된 키워드와 계층 반환 |
| `calculate_score(matches)` | 매칭 키워드 → 총점 계산 |
| `filter_articles(articles, min_score=1)` | `min_score` 미만 기사 제거 |

#### `landing_page.py` — 랜딩페이지 크롤러

| 클래스/메서드 | 설명 |
|--------------|------|
| `LandingPageCrawler` | Playwright headless Chromium 기반 |
| `crawl()` async | `https://incar-top1.tistory.com` networkidle 대기 + 스크롤 시뮬레이션 |
| `_parse_content(html)` | BeautifulSoup → `{headline, services, benefits, images, cta}` |

---

### 2.6 `scheduler/` — 자동 스케줄링

#### `daily_runner.py` — V2 일일 파이프라인 오케스트레이터

| 함수 | 설명 |
|------|------|
| `run_daily_pipeline()` | 7단계 전체 파이프라인 실행 |
| `crawl_sources()` | `crawl_rss()` + `crawl_youtube()` 병렬 실행 |
| `select_topics()` | `select_daily_topics()` 호출 |
| `generate_contents(topics, sources)` | `ContentGeneratorV2.generate()` per topic; `_build_context()`로 RSS/YouTube 원문 주입 |
| `_build_context(topic, rss_items, youtube_items)` | 업계동향 토픽의 키워드로 RSS/YouTube 매칭 |
| `fill_with_evergreen(passed, target_count=10)` | 규정 미달 콘텐츠 수 보충을 위해 미사용 카테고리 토픽 추가 |
| `render_cardnews(contents)` | `CardNewsRenderer.render_from_slides()` per content + 랜덤 테마 |
| `build_daily_queue(rendered)` | 발행 큐 dict 구성 |
| `save_queue(queue, date)` | `scheduler/daily_queue/YYYY-MM-DD.json` 저장 |

#### `auto_publisher.py` — 골든타임 스케줄 생성기

골든타임 정의:

| 시간대 | 슬롯 수 | 비율 |
|--------|---------|------|
| 출근 (07~09시) | 3개 | 30% |
| 점심 (12~13시) | 2개 | 20% |
| 퇴근 후 (19~22시) | 2개 | 20% |
| **골든 소계** | **7개** | **70%** |
| 비골든 (10~11시, 14~18시) | 3개 | 30% |

| 함수 | 설명 |
|------|------|
| `apply_jitter(time, jitter_minutes=±5~15)` | 무작위 지터(±5~15분) 적용 |
| `enforce_min_interval(times, min_interval=10분)` | 슬롯 간 최소 10분 간격 보장 |
| `generate_daily_schedule(target_date)` | 날짜별 10슬롯 시각 생성 → `scheduler/daily_schedule.json` 저장 |

#### `cron_runner.py` — APScheduler 크론 실행기

| 클래스/메서드 | 설명 |
|--------------|------|
| `CronRunner` | APScheduler `AsyncIOScheduler` 래퍼 |
| `_setup_jobs()` | `DAILY_SCHEDULE` 10개 슬롯을 크론 잡으로 등록 |
| `_execute_slot(slot)` | `PipelineOrchestrator().run_single()` 호출; 실패 시 5분 후 1회 재시도 |
| `_schedule_retry(slot, delay=5분)` | `DateTrigger` 일회성 재시도 잡 등록 |
| `start()` | 스케줄러 시작 |
| `stop()` | 스케줄러 정지 |
| `get_next_run_times()` | 다음 실행 예정 시각 목록 반환 |

#### `token_refresher.py` — 토큰 자동 갱신

| 클래스/메서드 | 설명 |
|--------------|------|
| `TokenRefresher` | 매일 03:00 KST 크론 잡 |
| `check_and_refresh()` | 토큰 로드 → 만료 7일 이내이면 `refresh_long_lived_token()` 자동 갱신 |
| `get_token_status()` | `{valid, days_remaining, expires_at}` |

#### `publish_worker.py` — 발행 워커

| 상수 | 값 |
|------|-----|
| `MAX_RETRIES` | 3 |
| `FAILURE_ALERT_THRESHOLD` | 하루 3건 실패 시 알림 |
| `THREADS_DAILY_LIMIT` | 250건 |
| `RATE_LIMIT_ALERT_RATIO` | 0.8 (80% 도달 시 경고) |

| 함수 | 설명 |
|------|------|
| `run_worker_cycle()` | 일일 큐 로드 → `get_pending_posts()` → `publish_post()` → `update_post_status()` → `log_publish()` |
| `publish_post(post)` | 이미지 수에 따라 carousel/single/text 선택 발행 |
| `get_pending_posts(queue)` | 상태가 pending인 게시물 필터링 |
| `update_post_status(queue, post_id, status)` | 큐 파일 상태 갱신 |
| `log_publish(post, result)` | 발행 결과 로그 |
| `_send_alert(message)` | 실패/경고 알림 (현재 logging.WARNING; Slack/이메일/SMS 미구현) |
| `run_daemon(interval=60)` | 60초 주기 무한 루프 |

#### `scheduler_data.py` (pipeline/)

| 항목 | 내용 |
|------|------|
| `DAILY_SCHEDULE` | 10개 슬롯 (08:00 TypeA → 21:30 TypeE) |
| `get_schedule_for_time(time)` | 시각 → 해당 슬롯 반환 |
| `get_next_schedule(now)` | 현재 시각 이후 다음 슬롯 반환 |
| `get_template_type_for_slot(slot_id)` | 슬롯 ID → TypeA~TypeE 매핑 |

---

### 2.7 `auth/` — 인증·토큰 관리

#### `oauth.py`

| 함수 | 설명 |
|------|------|
| `get_authorize_url()` | Threads OAuth 인증 페이지 URL 생성 |
| `exchange_code_for_token(code)` | 인증 코드 → 단기 액세스 토큰 교환 (httpx.Client) |
| `exchange_long_lived_token(short_token)` | 단기 → 장기(60일) 토큰 교환 |
| `refresh_long_lived_token(token)` | 장기 토큰 갱신 |

#### `token_store.py`

| 상수/함수 | 설명 |
|----------|------|
| `TOKEN_FILE` | `.tokens/token.json` |
| `_REFRESH_THRESHOLD_DAYS` | 7일 (만료 7일 이내 자동 갱신) |
| `save_token(token_data)` | 토큰 데이터 JSON 저장 |
| `load_token()` | 토큰 파일 로드 |
| `is_token_expired(token_data)` | 만료 여부 확인 |
| `_is_token_near_expiry(token_data)` | 7일 이내 만료 여부 확인 |
| `get_valid_token()` | 로드 → 만료 확인 → near_expiry 시 자동 갱신 → 액세스 토큰 문자열 반환 |

---

### 2.8 `api/` — Threads API 클라이언트

#### `client.py`

| 클래스/메서드 | 설명 |
|--------------|------|
| `ThreadsClient(access_token)` | httpx AsyncClient 기반 Threads Graph API v1.0 클라이언트 |
| `get_profile()` | 프로필 정보 조회 → `ThreadsProfile` |
| `post_text(text)` | 텍스트 게시물 발행 → threads_post_id |
| `post_image(image_url, caption)` | 이미지 컨테이너 생성 (30초 sleep 후) → 발행 → threads_post_id |
| `post_carousel(image_urls, caption)` | 멀티 이미지 컨테이너 생성 → carousel 컨테이너 → 발행 → threads_post_id |
| `_create_container(**params)` | 미디어 컨테이너 생성 API 호출 |
| `_publish(creation_id)` | 컨테이너 발행 API 호출 |
| `_request(method, url, **kwargs)` | HTTP 요청 + 429 지수 백오프 재시도 (1→2→4초, 최대 3회) |

#### `models.py`

| 모델 | 설명 |
|------|------|
| `MediaType` | Enum: TEXT / IMAGE / CAROUSEL |
| `ThreadsProfile` | `id`, `username`, `name`, `biography`, `followers_count` |
| `CreateContainerRequest` | 컨테이너 생성 요청 파라미터 |
| `CreateContainerResponse` | `id` (creation_id) |
| `PublishRequest` | `creation_id` |
| `PublishResponse` | `id` (threads_post_id) |
| `ThreadsError` | `code`, `message`, `type` |

---

### 2.9 `monitor/` — 상태 모니터링

#### `health_check.py`

| 클래스/메서드 | 설명 |
|--------------|------|
| `HealthChecker` | 시스템 전반 상태 점검 |
| `check_all()` | `{api_status, token_status, today_stats, last_crawl, overall}` |
| `_check_api()` | `graph.threads.net` HEAD 요청으로 API 연결 확인 |
| `_check_token()` | 토큰 유효성 + 남은 일수 확인 |
| `_check_today_stats()` | 오늘 발행 수 집계 |
| `_check_last_crawl()` | `output/` 디렉토리 내 최신 PNG mtime으로 마지막 크롤 시각 추정 |
| overall 판정 | `critical`(API 오류 or 토큰 만료) / `warning`(만료 7일 이내) / `healthy` |

#### `notifier.py`

| 클래스/메서드 | 설명 |
|--------------|------|
| `TelegramNotifier` | Telegram 알림 발송 |
| `notify_failure(error_msg)` | 파이프라인 실패 알림 |
| `notify_daily_summary(stats)` | 일일 발행 요약 알림 |
| `notify_token_warning(days_remaining)` | 토큰 만료 임박 경고 알림 |
| `_send(message)` | `cokacdir --cron` subprocess 호출로 발송; 실패 시 logging fallback |

---

### 2.10 `storage/` — 데이터 저장

#### `firestore.py`

| 클래스/메서드 | 설명 |
|--------------|------|
| `FirestoreClient` | Firebase Firestore (project: `insuwiki-j2h`) |
| `save_post(post_data)` | `ta_posts` 컬렉션에 게시물 저장 |
| `save_source(source_data)` | `ta_sources` 컬렉션에 소스 저장 |
| `save_history(post_id, published_at)` | `ta_history` 컬렉션에 발행 이력 저장 |
| `get_recent_posts(limit=30)` | 최근 게시물 조회 |
| 로컬 fallback | Firestore 미연결 시 `output/.firestore_fallback/{collection}.json` (UUID doc ID) |

#### `image_upload.py`

| 클래스/메서드 | 설명 |
|--------------|------|
| `ImageUploader(mode="local"/"gcs")` | 이미지 업로드 추상 클래스 |
| `upload(local_path)` | 이미지 업로드 |
| `get_public_url(local_path)` | 공개 URL 반환 |
| GCS 모드 | `NotImplementedError` — **미구현** |

---

### 2.11 `pipeline/` — 레거시 파이프라인

#### `orchestrator.py` — V1 파이프라인 오케스트레이터

| 클래스/메서드 | 설명 |
|--------------|------|
| `PipelineOrchestrator` | V1 레거시 파이프라인 |
| `collect_sources(source_type)` | `"news"` (RSS+filter), `"landing"` (Playwright 크롤), `"manual"` |
| `generate_content()` | ContentGenerator + `validate_content()` + `is_duplicate()` |
| `render_image(content, template_type)` | TypeA~TypeE 레거시 렌더러 클래스 호출 |
| `save_to_store(content, image_path)` | Firestore `ta_posts` + `ta_sources` 저장 |
| `run_single(template_type, source_type)` | 단건 V1 파이프라인 실행 |
| `run_batch(count=10)` | count건 V1 파이프라인 반복 실행 |

---

### 2.12 `web/` — 웹 대시보드

#### `routes.py` — 페이지 라우트

| 엔드포인트 | 설명 |
|-----------|------|
| `GET /web/login` | 로그인 페이지 |
| `POST /web/login` | 패스워드 인증 (SHA256 + SESSION_SECRET 해시); 성공 시 `ta_session` 쿠키 설정 |
| `GET /web/logout` | 세션 쿠키 삭제 |
| `GET /web/` | 대시보드 메인 (Jinja2 템플릿) |
| `GET /web/auth` | OAuth 인증 상태 페이지 |
| `GET /web/posts` | 게시물 목록 페이지 |
| `GET /web/posts/new` | 새 게시물 작성 폼 |
| `GET /web/scheduler` | 스케줄러 상태 페이지 |
| `GET /web/sources` | 소스(RSS/YouTube) 관리 페이지 |
| `GET /web/settings` | 설정 페이지 |

인증 방식: 쿠키 기반 (`ta_session`), 미인증 시 `/web/login`으로 302 리디렉션

#### `api.py` — AJAX API

| 엔드포인트 | 설명 |
|-----------|------|
| `GET /web/api/health` | 헬스체크 JSON |
| `GET /web/api/posts` | 게시물 목록 JSON |
| `POST /web/api/posts/publish` | 게시물 즉시 발행 |
| `GET /web/api/scheduler/status` | 스케줄러 상태 JSON |
| `POST /web/api/scheduler/toggle` | 스케줄러 시작/정지 토글 |
| `GET /web/api/sources` | 소스 목록 JSON |
| `POST /web/api/sources/add` | 소스 추가 |
| `POST /web/api/sources/delete` | 소스 삭제 |
| `GET /web/api/settings` | 설정 조회 |

인증 방식: 미인증 시 302 대신 **401 JSON** 반환 (AJAX 친화적)

---

### 2.13 `server/` — FastAPI 이미지 서빙

#### `image_server.py`

| 항목 | 설명 |
|------|------|
| `router` | FastAPI APIRouter |
| `IMAGE_DIR` | 프로젝트 루트 `output/` 디렉토리 |
| `ALLOWED_EXTENSIONS` | `{.png, .jpg, .jpeg, .gif, .webp}` |
| `GET /images/{filename}` | `output/` 디렉토리에서 이미지 파일 서빙; Content-Type 자동 설정 |
| 보안 | 경로 순회(`..`, `/`) 차단 (400); 허용 확장자 외 차단 (400); 파일 미존재 시 404 |

---

### 2.14 `video/` — 영상 생성 (Phase 1)

#### `config.py`

| 상수 | 값 |
|------|-----|
| `VIDEO_WIDTH` | 1080 px |
| `VIDEO_HEIGHT` | 1920 px (Reels/Shorts 세로 9:16) |
| `FPS` | 30 |
| `SLIDE_DURATION` | 3.0초 (슬라이드당 표시 시간) |
| `TRANSITION_DURATION` | 0.5초 (전환 효과 시간) |
| `OUTPUT_FORMAT` | `"mp4"` |
| `OUTPUT_CODEC` | `"libx264"` |
| `OUTPUT_DIR` | `/home/jay/projects/ThreadAuto/output/videos/` |

#### `effects.py`

| 함수 | 설명 |
|------|------|
| `fade_transition(clip1, clip2, duration=0.5)` | MoviePy 2.x `CrossFadeOut` + `CrossFadeIn` 조합으로 크로스 페이드 트랜지션 적용 후 `concatenate_videoclips(method="compose")` 반환 |

입력 검증: clip1/clip2 None 체크, duration > 0 체크, duration < 각 클립 duration 체크

#### `video_generator.py`

| 함수 | 설명 |
|------|------|
| `_load_clip(image_path)` | 단일 이미지 → MoviePy `ImageClip`; 실패 시 None (warning 로그) |
| `_resize_clip(clip)` | `VIDEO_WIDTH × VIDEO_HEIGHT` 리사이즈 |
| `generate_slideshow(image_paths, output_path, **kwargs)` | 이미지 목록 → fade 전환 → MP4 출력; 로드 실패 이미지 스킵; 클립 1개이면 전환 없이 단독 출력 |

입력 검증:
- `image_paths=None` → `TypeError`
- `image_paths=[]` → `ValueError`
- 모든 이미지 로드 실패 → `ValueError`

---

## 3. 파이프라인 흐름 요약

### V2 파이프라인 (현행 자동화)

```
[CronRunner: APScheduler]
    |
    ├── daily_runner.run_daily_pipeline()
    |       ├── crawl_sources() → rss_crawler + youtube_crawler
    |       ├── select_topics() → topic_selector.select_daily_topics()
    |       ├── generate_contents() → ContentGeneratorV2.generate() × N
    |       |       └── claude --print --model claude-sonnet-4-6
    |       ├── filter_content() → compliance_filter (3-layer)
    |       ├── fill_with_evergreen() → 부족분 보충
    |       ├── render_cardnews() → CardNewsRenderer.render_from_slides(theme)
    |       └── save_queue() → scheduler/daily_queue/YYYY-MM-DD.json
    |
    └── publish_worker.run_worker_cycle()
            ├── get_pending_posts()
            ├── publish_post() → ThreadsPublisher.publish_cardnews()
            |       ├── ImageServer.get_public_urls() (Tailscale Funnel)
            |       ├── ThreadsClient.post_carousel()
            |       └── FirestoreClient.save_history()
            └── update_post_status()
```

### 레거시 파이프라인 (V1)

```
PipelineOrchestrator.run_single()
    ├── collect_sources() → RSS 또는 Playwright
    ├── generate_content() → ContentGenerator (claude -p)
    ├── validate_content() + is_duplicate()
    ├── render_image() → TypeA~TypeE 템플릿 렌더러
    └── save_to_store() → Firestore ta_posts/ta_sources
```

### 수동 실행 스크립트

```
run_full_pipeline.py
    ├── select_single_topic()
    ├── ContentGeneratorV2.generate()
    ├── CardNewsRenderer.render_from_slides(get_random_theme())
    └── ThreadsPublisher.publish_cardnews()
```

---

## 4. 외부 연동 목록

| 서비스 | 용도 | 구현 위치 |
|--------|------|----------|
| **Threads Graph API v1.0** | 게시물 발행 (text/image/carousel) | `api/client.py` |
| **Claude CLI** (`claude --print`) | AI 텍스트 콘텐츠 생성 | `content/content_generator_v2.py`, `content/generator.py` |
| **Firebase Firestore** (insuwiki-j2h) | 게시물/소스/이력 영구 저장 | `storage/firestore.py` |
| **Tailscale Funnel** | 이미지 공개 URL 노출 (aidevserver) | `publisher/image_server.py` |
| **feedparser** | RSS 피드 파싱 | `crawler/rss_fetcher.py` |
| **yt-dlp** | YouTube 영상 목록 취득 | `crawler/youtube_crawler.py` |
| **youtube_transcript_api** | YouTube 자막 취득 | `crawler/youtube_crawler.py` |
| **Playwright (headless Chromium)** | 랜딩페이지 동적 크롤링 | `crawler/landing_page.py` |
| **APScheduler** (AsyncIOScheduler) | 크론 기반 자동 스케줄링 | `scheduler/cron_runner.py` |
| **Pillow (PIL)** | 이미지 렌더링 | `renderer/engine.py`, `renderer/cardnews.py` |
| **MoviePy 2.x + FFmpeg** | 슬라이드쇼 영상 생성 (MP4) | `video/video_generator.py`, `video/effects.py` |
| **Telegram** (cokacdir --cron) | 장애/경고 알림 발송 | `monitor/notifier.py` |
| **Google Cloud Storage (GCS)** | 이미지 업로드 (미구현) | `storage/image_upload.py` |
| **httpx** (async) | Threads API HTTP 통신 | `api/client.py` |
| **Jinja2** | 웹 대시보드 템플릿 렌더링 | `web/routes.py` |
| **BeautifulSoup4** | 랜딩페이지 HTML 파싱 | `crawler/landing_page.py` |
| **Typer + Rich** | CLI 인터페이스 | `cli.py` |
| **Pydantic** | API 스키마 검증 | `api/models.py` |

---

## 5. 주요 설정·데이터 파일

| 파일 경로 | 용도 |
|----------|------|
| `.env.keys` | 환경변수 (THREADS_APP_ID, THREADS_APP_SECRET 등) — git 제외 |
| `.tokens/token.json` | Threads 액세스 토큰 저장 |
| `content/evergreen_topics.json` | 에버그린 토픽 풀 (used_count, last_used 포함) |
| `content/news_cache.json` | RSS 크롤 결과 캐시 (7일 TTL) |
| `content/youtube_cache.json` | YouTube 크롤 결과 캐시 |
| `content/fact_db.md` | 규정 준수 필터용 허용 수치 레퍼런스 DB |
| `data/theme_history.json` | 테마 선택 이력 (last_theme, recent 5개) |
| `scheduler/daily_queue/YYYY-MM-DD.json` | 일일 발행 큐 파일 |
| `scheduler/daily_schedule.json` | 골든타임 기반 당일 스케줄 (10슬롯) |
| `output/` | 렌더링된 이미지 PNG 저장 디렉토리 |
| `output/videos/` | 생성된 슬라이드쇼 MP4 저장 디렉토리 |
| `output/.firestore_fallback/` | Firestore 미연결 시 로컬 JSON fallback |
| `pyproject.toml` | Python 프로젝트 설정 |
| `pytest.ini` | pytest 설정 |
| `requirements.txt` | 의존성 패키지 목록 |

---

## 6. 미구현·TODO 항목

| 항목 | 위치 | 상태 |
|------|------|------|
| GCS 이미지 업로드 | `storage/image_upload.py` | `NotImplementedError` (모드 선언만 존재) |
| Slack/이메일/SMS 장애 알림 | `scheduler/publish_worker.py` `_send_alert()` | `logging.WARNING` 으로 대체 중; TODO 주석 |
| `publish_worker.run_daemon()` 실제 서비스 통합 | `scheduler/publish_worker.py` | 독립 데몬으로 구현됐으나 main.py lifespan에 미통합 |
| 영상 생성 파이프라인 통합 | `video/` 모듈 전체 | Phase 1 완료 (생성 로직); Threads 영상 발행 연동 미완 |
| `generate_slideshow()` kwargs 미사용 | `video/video_generator.py` | `**kwargs` 선언됐으나 내부에서 사용 안 함 (확장 예정) |
| 웹 대시보드 `/web/posts/new` | `web/routes.py` | 폼 존재하나 실제 발행 연동 미확인 |
| 레거시 V1 파이프라인 유지 여부 | `pipeline/orchestrator.py`, `renderer/templates.py` | V2 전환 후 V1 코드 잔존; 점진적 제거 예정 여부 불명 |

---

## 7. 테스트 파일 목록

모든 테스트는 `tests/` 및 `video/tests/` 디렉토리에 위치한다. pytest 기반.

| 파일 | 대상 모듈 |
|------|----------|
| `tests/test_oauth.py` | `auth/oauth.py` |
| `tests/test_token_store.py` | `auth/token_store.py` |
| `tests/test_client.py` | `api/client.py` |
| `tests/test_carousel_api.py` | `api/client.py` carousel 발행 |
| `tests/test_renderer.py` | `renderer/` |
| `tests/test_cardnews_renderer.py` | `renderer/cardnews.py` |
| `tests/test_padding_consistency.py` | 렌더링 패딩 일관성 |
| `tests/test_cta_linebreak.py` | CTA 줄바꿈 처리 |
| `tests/test_themes.py` | `renderer/themes.py` |
| `tests/test_crawler.py` | `crawler/` |
| `tests/test_rss_crawler.py` | `crawler/rss_crawler.py` |
| `tests/test_youtube_crawler.py` | `crawler/youtube_crawler.py` |
| `tests/test_landing_page.py` | `crawler/landing_page.py` |
| `tests/test_content.py` | `content/` |
| `tests/test_content_generator_v2.py` | `content/content_generator_v2.py` |
| `tests/test_compliance_filter.py` | `content/compliance_filter.py` |
| `tests/test_topic_selector.py` | `content/topic_selector.py` |
| `tests/test_evergreen_topics.py` | 에버그린 토픽 풀 |
| `tests/test_pipeline.py` | `pipeline/orchestrator.py` |
| `tests/test_pipeline_v2.py` | `content/pipeline.py` |
| `tests/test_daily_runner.py` | `scheduler/daily_runner.py` |
| `tests/test_auto_publisher.py` | `scheduler/auto_publisher.py` |
| `tests/test_publish_worker.py` | `scheduler/publish_worker.py` |
| `tests/test_scheduler.py` | `scheduler/cron_runner.py` |
| `tests/test_publisher.py` | `publisher/threads_publisher.py` |
| `tests/test_image_server.py` | `publisher/image_server.py` |
| `tests/test_monitor.py` | `monitor/` |
| `tests/test_web_dashboard.py` | `web/` |
| `tests/pipeline_test_full.py` | 풀 파이프라인 통합 |
| `tests/test_task324_infobox.py` | task#324 인포박스 렌더링 |
| `video/tests/test_video_generator.py` | `video/video_generator.py` |
| `video/tests/test_effects.py` | `video/effects.py` |
| `video/tests/test_config.py` | `video/config.py` |

루트에 산재한 일회성 테스트 스크립트: `test_font_scale.py`, `test_full_pipeline.py`, `test_phaseA.py`, `test_pipeline_319.py`, `test_self_review.py`, `test_task327.py`, `test_weakness_improve.py`

---

*End of Feature Registry*
