Skill: rebugui/auto-sec-blogger (version 1.0.1)

--- name: auto-sec-blogger version: 1.1.0 description: AI-powered security blog automation system (identical to github.com/rebugui/intelligence-agent). Collects news from Google News, arXiv, HackerNews → generates blog posts with GLM-4.7 → publishes to Notion → auto-deploys to GitHub Pages via Git. Features Human-in-the-Loop approval workflow. Use when you want to automate blog writing, news collection, or content generation with the exact functionality of the original intelligence-agent repository. Triggers: "블로그 글 작성", "보안 뉴스 발행", "깃헙 블로그 발행", "intelligence agent", "지능형 에이전트", "자동 글쓰기". --- # Intelligence Agent ## 개요 보안 뉴스를 자동으로 수집하고, LLM(GLM-4.7)을 사용하여 전문가 수준의 블로그 글을 작성한 후, Notion과 GitHub Pages에 자동으로 게시하는 시스템입니다. **GitHub 저장소와 동일**: https://github.com/rebugui/intelligence-agent ## 아키텍처 ``` 뉴스 수집 (Google News, arXiv, HackerNews) ↓ GLM-4.7 글 작성 (전문 보안 블로그) ↓ Notion Draft 저장 (상태: Draft) ↓ 사용자 검토 및 승인 (Human-in-the-Loop) ↓ Git Push → GitHub Actions → GitHub Pages ``` ## 주요 기능 ### 1. 뉴스 수집 (News Collection) - **Google News**: 키워드 기반 보안 뉴스 수집 - **arXiv**: 최신 보안 연구 논문 수집 - **HackerNews**: 트렌딩 기술 뉴스 수집 - **중복 제거**: URL 기반 중복 뉴스 필터링 ### 2. LLM 글쓰기 (Content Generation) - **모델**: GLM-4.7 (Zhipu AI) - **스타일**: 전문 보안 블로그 - **구조**: - 제목 (헤드라인) - 요약 (3줄 요약) - 본문 (상세 분석) - 결론 (시사점) - 태그 (키워드) - **Mermaid 다이어그램**: 공격 흐름, 아키텍처 시각화 ### 3. Notion 통합 (Notion Integration) - **상태 관리**: Draft → Review → Approved → Published - **자동 저장**: 생성된 글 자동 저장 - **사용자 승인**: Notion에서 상태 변경으로 배포 승인 ### 4. Git 기반 발행 (Git Publishing) - **자동 커밋**: 마크다운 파일 Git에 커밋 - **GitHub Actions**: 자동 Jekyll 빌드 - **GitHub Pages**: 정적 블로그 배포 ## 설치 ### 1. 의존성 설치 ```bash cd ~/.openclaw/workspace/skills/intelligence-agent/scripts pip3 install -r requirements.txt ``` ### 2. 환경 변수 설정 ```bash # ~/.openclaw/workspace/.env # GLM API GLM_API_KEY=your_glm_api_key GLM_BASE_URL=https://api.z.ai/api/coding/paas/v4 # Notion NOTION_API_KEY=ntn_xxx NOTION_DATABASE_ID=xxx # GitHub Pages GITHUB_TOKEN=ghp_xxx GITHUB_BLOG_REPO=username/username.github.io BLOG_LOCAL_PATH=/path/to/blog/repo ``` ## 사용법 ### 1. 전체 파이프라인 실행 (테스트용) ```bash cd ~/.openclaw/workspace/skills/intelligence-agent/scripts python3 intelligence_pipeline.py --max-articles 5 ``` ### 2. 뉴스 수집만 ```python from collector import NewsCollector collector = NewsCollector() articles = collector.fetch_all(max_results_per_source=15) ``` ### 3. 블로그 글 작성만 ```python from writer import BlogWriter writer = BlogWriter() post = writer.generate_article(article_data) ``` ### 4. Notion 발행만 ```python from notion_publisher import NotionPublisher publisher = NotionPublisher() result = publisher.create_article(blog_post) ``` ### 5. Git 발행만 ```python from git_publisher_service import GitPublisherService git_publisher = GitPublisherService() git_publisher.publish(blog_posts) ``` ## 워크플로우 상세 ### 1단계: 뉴스 수집 ```python # collector.py class NewsCollector: def fetch_google_news(self, query="security vulnerability"): # Google News RSS 피드에서 수집 pass def fetch_arxiv(self, category="cs.CR"): # arXiv 보안 논문 수집 pass def fetch_hackernews(self): # HackerNews 트렌딩 기사 수집 pass ``` ### 2단계: AI 기사 선별 ```python # selector.py class ArticleSelector: async def evaluate_and_select(self, articles, max_articles=5): # GLM-4.7으로 기사 품질 평가 # 점수 기반 상위 기사 선별 pass ``` ### 3단계: 블로그 글 작성 ```python # writer.py class BlogWriter: async def generate_article(self, article): # GLM-4.7으로 블로그 글 작성 # Mermaid 다이어그램 생성 # 마크다운 형식 출력 pass ``` ### 4단계: Notion 발행 ```python # notion_publisher.py class NotionPublisher: def create_article(self, blog_post): # Notion DB에 Draft 상태로 저장 # 상태: Draft → Review → Approved pass ``` ### 5단계: Git 발행 (사용자 승인 후) ```python # git_publisher_service.py class GitPublisherService: def publish(self, blog_posts): # 마크다운 파일 생성 # Git commit & push # GitHub Actions 트리거 pass ``` ## Cron 스케줄링 ### 매일 08:30 자동 실행 ```python # intelligence_pipeline.py from apscheduler.schedulers.blocking import BlockingScheduler scheduler = BlockingScheduler() scheduler.add_job(run_pipeline, 'cron', hour=8, minute=30) scheduler.start() ``` ## Notion 데이터베이스 구조 ### 필수 속성 | 속성명 | 타입 | 설명 | |--------|------|------| | 제목 | title | 블로그 글 제목 | | 상태 | select | Draft/Review/Approved/Published | | 날짜 | date | 발행일 | | 태그 | multi_select | 키워드 | | URL | url | 원문 URL | | 카테고리 | select | 취약점/연구/트렌드 | ## Jekyll 블로그 구조 ``` blog/ ├── _posts/ │ ├── 2025-03-09-cve-2025-xxxx-analysis.md │ ├── 2025-03-09-ai-security-trends.md │ └── ... ├── _layouts/ │ ├── post.html │ └── default.html ├── _config.yml └── .github/ └── workflows/ └── jekyll.yml ``` ## 트러블슈팅 ### GLM API Rate Limit ``` ❌ Error: Rate limit reached (429) ``` **해결**: - 자동 재시도 3회 - 60초 대기 후 재시도 ### Notion API Error ``` ❌ Error: Notion API error ``` **해결**: - API 키 확인 - Database ID 확인 - Integration 권한 확인 ### Git Push 실패 ``` ❌ Error: Git push failed ``` **해결**: - GitHub Token 확인 - 원격 저장소 권한 확인 - 브랜치 확인 ## 파일 구조 ``` intelligence-agent/ ├── SKILL.md (이 파일) ├── scripts/ │ ├── intelligence_pipeline.py (메인 파이프라인) │ ├── collector.py (뉴스 수집) │ ├── selector.py (AI 기사 선별) │ ├── writer.py (블로그 글 작성) │ ├── notion_publisher.py (Notion 발행) │ ├── git_publisher_service.py (Git 발행) │ ├── llm_client.py (GLM API 클라이언트) │ ├── llm_client_async.py (비동기 GLM 클라이언트) │ ├── prompt_manager.py (프롬프트 관리) │ ├── prompts.yaml (프롬프트 템플릿) │ ├── models.py (데이터 모델) │ ├── utils.py (유틸리티) │ ├── config.py (설정) │ └── requirements.txt (의존성) └── references/ ├── architecture.md (상세 아키텍처) ├── prompts_guide.md (프롬프트 가이드) └── api_reference.md (API 레퍼런스) ``` ## 환경 변수 ### 필수 ```bash GLM_API_KEY # GLM-4.7 API 키 NOTION_API_KEY # Notion API 키 NOTION_DATABASE_ID # Notion 데이터베이스 ID ``` ### 선택사항 ```bash GITHUB_TOKEN # GitHub 개인 액세스 토큰 GITHUB_BLOG_REPO # GitHub 블로그 저장소 (username/repo) BLOG_LOCAL_PATH # 로컬 블로그 경로 ``` ## 테스트 ### 전체 파이프라인 테스트 ```bash python3 test_full_pipeline.py ``` ### Mermaid 다이어그램 테스트 ```bash python3 test_mermaid_fix.py ``` ## 참고자료 - [GitHub 저장소](https://github.com/rebugui/intelligence-agent) - [GLM API 문서](https://open.bigmodel.cn/dev/api) - [Notion API 문서](https://developers.notion.com/) - [Jekyll 문서](https://jekyllrb.com/docs/) ## 리소스 ### scripts/ 원본 저장소의 모든 Python 스크립트 포함: - `intelligence_pipeline.py` - 전체 파이프라인 실행 - `collector.py` - 뉴스 수집기 - `selector.py` - AI 기사 선별 - `writer.py` - 블로그 글 작성 - `notion_publisher.py` - Notion 발행 - `git_publisher_service.py` - Git 발행 - `llm_client.py` - GLM API 클라이언트 - `prompts.yaml` - 프롬프트 템플릿 ### references/ - `architecture.md` - 상세 아키텍처 설명 - `prompts_guide.md` - 프롬프트 작성 가이드 - `api_reference.md` - API 레퍼런스

path: references/architecture.md
content: # Intelligence Agent Architecture

## 시스템 개요

Intelligence Agent는 4단계 파이프라인으로 구성된 자동화 시스템입니다.

```
┌─────────────────────────────────────────────────────────────┐
│                    Intelligence Pipeline                      │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1️⃣ Collector          2️⃣ Selector          3️⃣ Writer     │
│  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐│
│  │Google News   │────▶│ GLM-4.7      │────▶│ GLM-4.7      ││
│  │arXiv         │     │ 품질 평가    │     │ 글 작성      ││
│  │HackerNews    │     │ 점수 매기기  │     │ Mermaid      ││
│  └──────────────┘     └──────────────┘     └──────────────┘│
│         │                    │                    │         │
│         ▼                    ▼                    ▼         │
│    100+ 기사            5개 선별            5개 글          │
│                                                              │
│  4️⃣ Notion Publisher          5️⃣ Git Publisher             │
│  ┌──────────────┐            ┌──────────────┐               │
│  │ Draft 저장   │───────────▶│ Git Push     │               │
│  │ 상태 관리    │  승인 후   │ GitHub Pages │               │
│  │ Human Loop   │            │ 배포         │               │
│  └──────────────┘            └──────────────┘               │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```

## 컴포넌트 상세

### 1. Collector (뉴스 수집기)

**목적**: 다양한 소스에서 최신 보안 뉴스 수집

**소스**:
- **Google News**: RSS 피드 기반 키워드 검색
- **arXiv**: cs.CR (Cryptography and Security) 카테고리
- **HackerNews**: 트렌딩 기술 뉴스

**구현**:
```python
class NewsCollector:
    def fetch_google_news(self, query="security vulnerability"):
        # RSS 피드 파싱
        # 중복 URL 제거
        # 메타데이터 추출 (제목, 날짜, URL, 요약)

    def fetch_arxiv(self, category="cs.CR"):
        # arXiv API 호출
        # 최신 논문 메타데이터 추출

    def fetch_hackernews(self):
        # HackerNews API 호출
        # 인기 기사 추출
```

**출력**: `Article` 객체 리스트
```python
{
    'title': '새로운 제로데이 취약점 발견',
    'url': 'https://...',
    'source': 'Google News',
    'date': '2025-03-09',
    'summary': '요약 내용...',
    'content': '본문...'
}
```

### 2. Selector (AI 기사 선별)

**목적**: 수집된 기사 중 고품질 기사 선별

**평가 기준** (GLM-4.7):
- **관련성** (1-10): 보안 분야 관련성
- **시의성** (1-10): 최신 이슈 여부
- **영향도** (1-10): 실무 영향도
- **흥미도** (1-10): 독자 흥미 유발

**구현**:
```python
class ArticleSelector:
    async def evaluate_article(self, article):
        # GLM-4.7으로 기사 평가
        prompt = f"""
        다음 기사를 평가하세요:
        제목: {article['title']}
        요약: {article['summary']}

        평가 기준:
        - 관련성 (1-10)
        - 시의성 (1-10)
        - 영향도 (1-10)
        - 흥미도 (1-10)
        """

        response = await self.llm_client.generate(prompt)
        return parse_score(response)

    async def evaluate_and_select(self, articles, max_articles=5):
        # 병렬 평가
        tasks = [self.evaluate_article(a) for a in articles]
        results = await asyncio.gather(*tasks)

        # 점수 합산 및 정렬
        scored = sorted(results, key=lambda x: x['total_score'], reverse=True)

        # 상위 N개 반환
        return scored[:max_articles]
```

**출력**: 상위 5개 기사

### 3. Writer (블로그 글 작성)

**목적**: GLM-4.7으로 전문적인 블로그 글 작성

**구조**:
1. **제목** (헤드라인)
2. **요약** (3줄 요약)
3. **본문** (상세 분석)
4. **Mermaid 다이어그램** (시각화)
5. **결론** (시사점)
6. **태그** (키워드)

**구현**:
```python
class BlogWriter:
    async def generate_article(self, article):
        # 1. 기본 정보
        title = self._generate_title(article)
        summary = await self._generate_summary(article)

        # 2. 상세 분석
        analysis = await self._generate_analysis(article)

        # 3. Mermaid 다이어그램
        diagram = await self._generate_mermaid(article)

        # 4. 결론
        conclusion = await self._generate_conclusion(article)

        # 5. 태그
        tags = await self._extract_tags(article)

        return BlogPost(
            title=title,
            summary=summary,
            content=analysis,
            diagram=diagram,
            conclusion=conclusion,
            tags=tags
        )
```

**Mermaid 다이어그램 예시**:
```mermaid
graph TD
    A[공격자] --> B[취약점 탐지]
    B --> C[익스플로잇 개발]
    C --> D[초기 침투]
    D --> E[권한 상승]
    E --> F[데이터 유출]
```

### 4. Notion Publisher (Notion 발행)

**목적**: 생성된 글을 Notion에 Draft 상태로 저장

**상태 관리**:
```
Draft → Review → Approved → Published
```

**구현**:
```python
class NotionPublisher:
    def create_article(self, blog_post):
        # Notion API 호출
        response = requests.post(
            f"https://api.notion.com/v1/pages",
            headers={
                "Authorization": f"Bearer {NOTION_API_KEY}",
                "Notion-Version": "2022-06-28"
            },
            json={
                "parent": {"database_id": DATABASE_ID},
                "properties": {
                    "제목": {"title": [{"text": {"content": blog_post.title}}]},
                    "상태": {"select": {"name": "Draft"}},
                    "날짜": {"date": {"start": datetime.now().isoformat()}},
                    "태그": {"multi_select": [{"name": tag} for tag in blog_post.tags]}
                }
            }
        )

        return response.json()
```

**Human-in-the-Loop**:
1. Notion에서 Draft 상태 글 확인
2. 내용 검토 및 수정
3. 상태를 "Approved"로 변경
4. Git Publisher가 Approved 글만 발행

### 5. Git Publisher (Git 발행)

**목적**: Approved 글을 GitHub Pages에 발행

**프로세스**:
1. 로컬 블로그 저장소 클론
2. 마크다운 파일 생성 (`_posts/YYYY-MM-DD-title.md`)
3. Jekyll Front Matter 추가
4. Git commit & push
5. GitHub Actions 트리거
6. Jekyll 빌드 & 배포

**구현**:
```python
class GitPublisherService:
    def publish(self, blog_posts):
        # 1. 로컬 저장소 확인
        if not os.path.exists(BLOG_LOCAL_PATH):
            self._clone_repo()

        # 2. 마크다운 파일 생성
        for post in blog_posts:
            if post.status == "Approved":
                self._create_markdown_file(post)

        # 3. Git 작업
        self._git_commit()
        self._git_push()

        # 4. GitHub Actions 트리거
        print("✅ GitHub Actions가 자동으로 실행됩니다")

    def _create_markdown_file(self, post):
        # Jekyll Front Matter
        front_matter = f"""---
layout: post
title: "{post.title}"
date: {post.date}
categories: security
tags: {post.tags}
---
"""

        # 파일 저장
        filename = f"{post.date.strftime('%Y-%m-%d')}-{self._slugify(post.title)}.md"
        filepath = os.path.join(BLOG_LOCAL_PATH, "_posts", filename)

        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(front_matter + post.content)
```

## 데이터 흐름

```
1. 수집 (100+ 기사)
   ↓
2. 평가 & 선별 (5개)
   ↓
3. 글 작성 (5개)
   ↓
4. Notion 저장 (5개 Draft)
   ↓
5. 사용자 승인 (2개 Approved)
   ↓
6. Git 발행 (2개)
   ↓
7. GitHub Pages 배포 (2개)
```

## 비동기 처리

**병렬 실행**:
```python
# 기사 평가 병렬화
tasks = [selector.evaluate_article(a) for a in articles]
results = await asyncio.gather(*tasks)

# 글 작성 병렬화
tasks = [writer.generate_article(a) for a in selected]
posts = await asyncio.gather(*tasks)
```

**장점**:
- 5개 기사 평가: 10초 → 2초 (5x 빠름)
- 5개 글 작성: 50초 → 10초 (5x 빠름)

## 에러 처리

**재시도 로직**:
```python
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=4, max=10)
)
async def call_glm_api(prompt):
    response = await client.generate(prompt)
    return response
```

**Rate Limit 처리**:
```python
try:
    response = await call_glm_api(prompt)
except RateLimitError:
    await asyncio.sleep(60)
    response = await call_glm_api(prompt)
```

## 모니터링

**로그 구조**:
```python
logger.info(f"[1/4] Collecting news from {len(sources)} sources...")
logger.info(f"[2/4] AI evaluating {len(articles)} articles...")
logger.info(f"[3/4] Writing {len(selected)} blog posts...")
logger.info(f"[4/4] Publishing {len(posts)} to Notion...")
logger.info(f"✅ Pipeline completed: {success_count}/{total_count}")
```

**메트릭**:
- 수집된 기사 수
- 선별된 기사 수
- 작성된 글 수
- 발행된 글 수
- 소요 시간

## 보안

**API 키 관리**:
- 환경 변수로 관리
- `.env` 파일은 `.gitignore`에 추가
- 절대 코드에 하드코딩하지 않음

**Git Token 권한**:
- `repo` 스코프만 허용
- 정기적으로 토큰 갱신
- 최소 권한 원칙 준수


path: scripts/__init__.py
content: """
Intelligence Agent Module

보안 블로그 자동화 시스템:
- News Collector: RSS/News 수집
- LLM Writer: 보안 전문가 페르소나로 글 작성
- Notion Publisher: Notion DB 저장
- Git Publisher: 사용자 승인 후 Git Push → GitHub Actions 배포
"""

# Intelligence Agent 하위 모듈
from modules.intelligence.collector import NewsCollector
from modules.intelligence.writer import BlogWriter
from modules.intelligence.llm_client import GLMClient
from modules.intelligence.notion_publisher import NotionPublisher
from modules.intelligence.publisher_git import GitPublisher

__all__ = [
    "NewsCollector",
    "BlogWriter",
    "GLMClient",
    "NotionPublisher",
    "GitPublisher",
    "__all__",
    "__version__",
    "__author__",
]

__version__ = "1.0.0"
__author__ = "OpenClaw Team"


path: scripts/auto_publish_approved.py
content: #!/usr/bin/env python3
"""
Auto-Sec-Blogger 자동 발행 스크립트
3시간마다 Notion에서 "검토 완료" 상태인 글을 찾아 블로그에 발행
"""

import os
import sys
import requests
import subprocess
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv

# .env 로드
load_dotenv('/Users/rebugui/.openclaw/workspace/.env')

notion_token = os.getenv('NOTION_API_KEY')
database_id = os.getenv('BLOG_DATABASE_ID')
blog_path = Path.home() / '.openclaw' / 'workspace' / 'blog'
posts_dir = blog_path / 'content' / 'post'

# 로그 파일
log_file = Path.home() / '.openclaw' / 'logs' / 'auto-publish-approved.log'

def log(message):
    """로그 기록"""
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    log_message = f"[{timestamp}] {message}"
    print(log_message)
    
    # 로그 파일에도 기록
    log_file.parent.mkdir(parents=True, exist_ok=True)
    with open(log_file, 'a', encoding='utf-8') as f:
        f.write(log_message + '\n')

def get_approved_articles():
    """Notion에서 "검토 완료" 상태인 글 조회"""
    headers = {
        "Authorization": f"Bearer {notion_token}",
        "Notion-Version": "2022-06-28",
        "Content-Type": "application/json"
    }
    
    # "검토 완료" 상태인 글 조회
    query_url = f"https://api.notion.com/v1/databases/{database_id}/query"
    payload = {
        "filter": {
            "property": "상태",
            "status": {
                "equals": "검토 완료"
            }
        },
        "sorts": [{"timestamp": "created_time", "direction": "descending"}],
        "page_size": 50
    }
    
    try:
        response = requests.post(query_url, headers=headers, json=payload)
        response.raise_for_status()
        
        data = response.json()
        results = data['results']
        
        log(f"📊 '검토 완료' 상태인 글: {len(results)}개 발견")
        
        articles = []
        for page in results:
            # 제목 추출
            title = "제목 없음"
            title_prop = page['properties'].get('내용')
            if title_prop and title_prop.get('title'):
                title = title_prop['title'][0]['plain_text']
            
            # 카테고리 추출
            category = "security"
            category_prop = page['properties'].get('카테고리')
            if category_prop and category_prop.get('select'):
                category = category_prop['select']['name']
            
            # URL 추출
            url = ""
            url_prop = page['properties'].get('URL')
            if url_prop and url_prop.get('url'):
                url = url_prop['url']
            
            # 본문 추출 (간단 버전)
            content = "내용 없음"
            
            articles.append({
                'title': title,
                'category': category,
                'url': url,
                'content': content,
                'posting_date': datetime.now(),
                'page_id': page['id']
            })
        
        return articles
        
    except Exception as e:
        log(f"❌ Notion 조회 실패: {e}")
        return []

def sanitize_filename(title):
    """파일명으로 사용 가능한 문자열로 변환"""
    import re
    # 특수문자 제거
    filename = re.sub(r'[^\w\s-]', '', title)
    # 공백을 하이픈으로 변환
    filename = re.sub(r'[\s]+', '-', filename)
    # 최대 길이 제한
    return filename[:100]

def create_hugo_post(article):
    """Hugo 마크다운 포스트 생성"""
    import re
    
    date_str = article['posting_date'].strftime('%Y-%m-%d')
    time_str = article['posting_date'].strftime('%H:%M:%S')
    filename_slug = sanitize_filename(article['title'])
    
    # Hugo content 구조: content/post/category/filename/index.md
    category = article['category']
    post_dir = posts_dir / category / filename_slug
    post_dir.mkdir(parents=True, exist_ok=True)
    filepath = post_dir / 'index.md'
    
    # 이미 존재하는 경우 스킵
    if filepath.exists():
        log(f"  ⏭️  이미 존재: {category}/{filename_slug}")
        return None
    
    # Hugo Front Matter
    front_matter = f"""---
title: "{article['title']}"
date: {date_str}T{time_str}+09:00
draft: false
categories: ["{category}"]
tags: ["{category}"]
author: "Intelligence Agent"
---

"""
    
    # 본문
    content = article['content']
    
    # 출처 추가
    source = ""
    if article['url']:
        source = f"\n\n---\n\n**출처**: [{article['category']}]({article['url']})"
    
    # 파일 작성
    with open(filepath, 'w', encoding='utf-8') as f:
        f.write(front_matter + content + source)
    
    return f"{category}/{filename_slug}/index.md"

def git_commit_and_push(filenames):
    """Git commit & push"""
    try:
        # Git add
        for filename in filenames:
            filepath = posts_dir / filename
            subprocess.run(['git', 'add', str(filepath)], cwd=blog_path, check=True, capture_output=True)
        
        # 커밋
        commit_msg = f"feat: 블로그 글 추가 - {len(filenames)}개 ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
        subprocess.run(['git', 'commit', '-m', commit_msg], cwd=blog_path, check=True, capture_output=True)
        
        # 푸시
        subprocess.run(['git', 'push', 'origin', 'main'], cwd=blog_path, check=True, capture_output=True)
        
        log(f"✅ Git push 완료: {len(filenames)}개 포스트")
        return True
        
    except subprocess.CalledProcessError as e:
        log(f"❌ Git 작업 실패: {e}")
        return False

def main():
    log("=" * 70)
    log("🚀 Auto-Sec-Blogger 자동 발행 시작")
    log("=" * 70)
    
    # 1. "검토 완료" 상태인 글 조회
    articles = get_approved_articles()
    
    if not articles:
        log("📭 발행할 글이 없습니다.")
        return
    
    # 2. Hugo 포스트 생성
    log(f"\n📝 Hugo 포스트 생성 중...")
    filenames = []
    for i, article in enumerate(articles, 1):
        log(f"  [{i}/{len(articles)}] {article['title'][:50]}")
        
        try:
            filename = create_hugo_post(article)
            if filename:
                filenames.append(filename)
                log(f"    ✅ 생성: {filename}")
        except Exception as e:
            log(f"    ❌ 실패: {str(e)[:50]}")
    
    # 3. Git commit & push
    if filenames:
        log(f"\n🔄 Git commit & push 중...")
        git_commit_and_push(filenames)
    else:
        log("\n📭 새로 발행할 글이 없습니다 (모두 이미 발행됨)")
    
    log("\n" + "=" * 70)
    log("✅ 자동 발행 완료")
    log("=" * 70)

if __name__ == "__main__":
    main()


path: scripts/collector.py
content: """
News Collector - RSS/News 수집 모듈

RSS feeds (Google News, arXiv, HackerNews, Hada.io, Geeknews)에서 보안 관련 뉴스를 수집합니다.
SQLite를 사용하여 수집 이력을 영구 저장하고 중복을 방지합니다.
"""

import feedparser
import arxiv
import sqlite3
from typing import List, Dict, Optional
from datetime import datetime
import re
from urllib.parse import quote
from modules.intelligence.config import DB_PATH
from modules.intelligence.utils import setup_logger

logger = setup_logger(__name__, "collector.log")

class NewsCollector:
    """뉴스 수집기"""

    def __init__(self, keywords: List[str] = None):
        """
        초기화

        Args:
            keywords: 필터링할 키워드 리스트 (기본값: 보안 관련 키워드)
        """
        if keywords is None:
            keywords = [
                # Security
                "Vulnerability", "Security", "Cybersecurity", "CVE", "Exploit",
                "Zero-day", "Penetration Testing", "Web Security", "Network Security",
                "Malware", "Ransomware", "Phishing", "Threat", "Attack",
                "Injection", "XSS", "CSRF", "RCE", "Authentication", "Authorization",

                # AI
                "Artificial Intelligence", "Machine Learning", "Deep Learning",
                "Neural Network", "Transformer", "GPT", "LLM", "Diffusion",
                "Computer Vision", "NLP", "MLOps", "Model", "Training", "Inference",

                # LLM
                "Large Language Model", "ChatGPT", "Claude", "GPT-4", "Llama",
                "Prompt Engineering", "RAG", "Fine-tuning", "RLHF", "Token",
                "Context Window", "AI Agent", "Hallucination", "Alignment",

                # Agent
                "Agent", "AI Agent", "Autonomous Agent", "Multi-agent", "Swarm",
                "Workflow", "Automation", "Tool Use", "Function Calling",
                "Agent Framework", "LangChain", "AutoGPT", "BabyAGI",

                # Research/Tech
                "Research", "Paper", "arXiv", "Open Source", "Framework",
                "Library", "API", "GitHub", "Release", "Update", "Patch"
            ]
        self.keywords = keywords
        self._init_db()

    def _init_db(self):
        """SQLite DB 초기화"""
        try:
            self.conn = sqlite3.connect(DB_PATH, check_same_thread=False)
            cursor = self.conn.cursor()
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS seen_articles (
                    url TEXT PRIMARY KEY,
                    collected_at TIMESTAMP
                )
            """)
            self.conn.commit()
        except Exception as e:
            logger.error(f"DB Initialization failed: {e}")
            raise

    def is_seen(self, url: str) -> bool:
        """URL 중복 확인"""
        try:
            cursor = self.conn.cursor()
            cursor.execute("SELECT 1 FROM seen_articles WHERE url = ?", (url,))
            return cursor.fetchone() is not None
        except Exception as e:
            logger.error(f"DB Check failed for {url}: {e}")
            return False

    def add_seen(self, url: str):
        """URL 수집 이력 저장"""
        try:
            cursor = self.conn.cursor()
            cursor.execute(
                "INSERT OR IGNORE INTO seen_articles (url, collected_at) VALUES (?, ?)",
                (url, datetime.now())
            )
            self.conn.commit()
        except Exception as e:
            logger.error(f"DB Insert failed for {url}: {e}")

    def __del__(self):
        """소멸자: DB 연결 종료"""
        if hasattr(self, 'conn') and self.conn:
            self.conn.close()

    def fetch_google_news(self, keyword: str = "Cybersecurity", max_results: int = 10) -> List[Dict]:
        """Google News 수집"""
        logger.info(f"Fetching Google News for '{keyword}'...")
        encoded_keyword = quote(keyword)
        url = f"https://news.google.com/rss/search?q={encoded_keyword}&hl=en-US&gl=US&ceid=US:en"
        
        try:
            feed = feedparser.parse(url)
            articles = []

            for entry in feed.entries[:max_results]:
                if self.is_seen(entry.link):
                    continue

                article = {
                    "source": "Google News",
                    "title": entry.title,
                    "url": entry.link,
                    "published": entry.get("published", ""),
                    "summary": entry.get("summary", ""),
                    "keyword": keyword
                }
                articles.append(article)
                self.add_seen(entry.link)

            logger.info(f"  → Found {len(articles)} new articles")
            return articles
        except Exception as e:
            logger.error(f"Failed to fetch Google News: {e}")
            return []

    def fetch_arxiv(self, categories: List[str] = None, max_results: int = 10) -> List[Dict]:
        """arXiv 논문 수집"""
        logger.info("Fetching arXiv papers...")
        if categories is None:
            categories = ["cs.CR", "cs.LG", "cs.SE"]

        try:
            articles = []
            search = arxiv.Search(
                query="cat:cs.CR OR cat:cs.LG OR cat:cs.SE",
                max_results=max_results,
                sort_by=arxiv.SortCriterion.SubmittedDate
            )

            for result in search.results():
                if self.is_seen(result.entry_id):
                    continue

                # 키워드 필터링
                title_lower = result.title.lower()
                summary_lower = result.summary.lower()
                if not any(keyword.lower() in title_lower or keyword.lower() in summary_lower
                        for keyword in self.keywords):
                    continue

                article = {
                    "source": "arXiv",
                    "title": result.title,
                    "url": result.entry_id,
                    "published": result.published.strftime("%Y-%m-%d %H:%M:%S"),
                    "summary": result.summary,
                    "authors": [author.name for author in result.authors],
                    "categories": result.categories
                }
                articles.append(article)
                self.add_seen(result.entry_id)

            logger.info(f"  → Found {len(articles)} new papers")
            return articles
        except Exception as e:
            logger.error(f"Failed to fetch arXiv: {e}")
            return []

    def fetch_hackernews(self, max_results: int = 10) -> List[Dict]:
        """HackerNews 수집"""
        logger.info("Fetching HackerNews...")
        url = "https://hnrss.org/frontpage"
        
        try:
            feed = feedparser.parse(url)
            articles = []

            for entry in feed.entries[:max_results]:
                if self.is_seen(entry.link):
                    continue

                title_lower = entry.title.lower()
                summary_lower = entry.get("summary", "").lower()
                if not any(keyword.lower() in title_lower or keyword.lower() in summary_lower
                        for keyword in self.keywords):
                    continue

                article = {
                    "source": "HackerNews",
                    "title": entry.title,
                    "url": entry.link,
                    "published": entry.get("published", ""),
                    "summary": entry.get("summary", ""),
                    "comments_url": entry.get("comments", "")
                }
                articles.append(article)
                self.add_seen(entry.link)

            logger.info(f"  → Found {len(articles)} new articles")
            return articles
        except Exception as e:
            logger.error(f"Failed to fetch HackerNews: {e}")
            return []

    def fetch_hadaio(self, max_results: int = 10) -> List[Dict]:
        """Hada.io 수집"""
        logger.info("Fetching Hada.io...")
        url = "https://news.hada.io/rss/news"
        
        try:
            feed = feedparser.parse(url)
            articles = []

            for entry in feed.entries[:max_results]:
                link = entry.get("link", entry.get("id", ""))
                if self.is_seen(link):
                    continue

                content = entry.get("content", [{}])[0].get("value", "") if entry.get("content") else ""
                summary_lower = entry.get("summary", content).lower()
                
                # Hada.io는 모두 수집 (필터링 최소화)
                article = {
                    "source": "Hada.io",
                    "title": entry.title,
                    "url": link,
                    "published": entry.get("published", ""),
                    "summary": summary_lower[:200] if summary_lower else "",
                    "comments_url": link
                }
                articles.append(article)
                self.add_seen(link)

            logger.info(f"  → Found {len(articles)} new articles")
            return articles
        except Exception as e:
            logger.error(f"Failed to fetch Hada.io: {e}")
            return []

    def fetch_geeknews(self, max_results: int = 10) -> List[Dict]:
        """Geeknews 수집"""
        return self.fetch_hadaio(max_results)

    def fetch_all(self, max_results_per_source: int = 10) -> List[Dict]:
        """모든 소스 수집"""
        all_articles = []

        for keyword in self.keywords[:3]:
            all_articles.extend(self.fetch_google_news(keyword, max_results_per_source))

        all_articles.extend(self.fetch_arxiv(max_results=max_results_per_source))
        all_articles.extend(self.fetch_hackernews(max_results=max_results_per_source))
        all_articles.extend(self.fetch_hadaio(max_results=max_results_per_source))
        all_articles.extend(self.fetch_geeknews(max_results=max_results_per_source))

        all_articles.sort(key=lambda x: x["published"], reverse=True)
        return all_articles

if __name__ == "__main__":
    collector = NewsCollector()
    articles = collector.fetch_all(max_results_per_source=2)
    print(f"Collected {len(articles)} new articles.")

path: scripts/config.py
content: """
Configuration Module
환경 변수 로드, 경로 설정, 상수 정의를 담당합니다.
"""

import os
from pathlib import Path
from dotenv import load_dotenv

# 1. 경로 설정
# 현재 파일 위치: modules/intelligence/config.py (symlink to submodules/intelligence-agent/src/)
# 프로젝트 루트: OpenClaw/
CURRENT_DIR = Path(__file__).resolve().parent

# 환경 변수로 지정된 PROJECT_ROOT 사용 (우선)
# 또는 절대 경로로 직접 설정
PROJECT_ROOT = Path(os.getenv("OPENCLAW_ROOT", "/Users/rebugui/.openclaw/workspace"))

# PROJECT_ROOT가 존재하는지 확인
if not PROJECT_ROOT.exists():
    # fallback: 상대 경로 계산
    PROJECT_ROOT = CURRENT_DIR.parent.parent

# 주요 디렉토리
LOG_DIR = PROJECT_ROOT / "logs"
DATA_DIR = PROJECT_ROOT / "data"
ARCHIVE_DIR = PROJECT_ROOT / "archive"

# 디렉토리 생성
LOG_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)

# 블로그 저장소 경로
BLOG_REPO_PATH_ENV = os.getenv("BLOG_REPO_PATH")
if BLOG_REPO_PATH_ENV:
    BLOG_REPO_PATH = Path(BLOG_REPO_PATH_ENV)
else:
    BLOG_REPO_PATH = PROJECT_ROOT / "blog"

# 블로그 URL
BLOG_URL = os.getenv("BLOG_URL", "https://rebugui.github.io/")

# 2. 환경 변수 로드
# 프로젝트 루트의 .env 파일 우선적으로 로드
env_path = PROJECT_ROOT / ".env"
if env_path.exists():
    load_dotenv(env_path, override=True)
else:
    # 현재 디렉토리의 .env 시도
    local_env = PROJECT_ROOT / ".env"
    if local_env.exists():
        load_dotenv(local_env, override=True)

# 3. 설정 값 가져오기
def get_env(key: str, default: str = None) -> str:
    """환경 변수 가져오기"""
    return os.getenv(key, default)

# API Keys (Intelligence Agent)
OPENAI_API_KEY = get_env("INTELLIGENCE_OPENAI_API_KEY")
GLM_API_KEY = get_env("INTELLIGENCE_LLM_API_KEY")
NOTION_API_KEY = get_env("INTELLIGENCE_NOTION_TOKEN")

# Blog Config
BLOG_REPO_PATH = Path(get_env("BLOG_REPO_PATH", str(BLOG_REPO_PATH)))
BLOG_URL = get_env("BLOG_URL", "https://rebugui.github.io/hate-coding-turtle/")

# Database Config (Intelligence Agent)
DB_PATH = DATA_DIR / "intelligence.db"

# Notion Database ID (Intelligence Agent)
NOTION_DATABASE_ID = get_env("INTELLIGENCE_BLOG_DATABASE_ID")

# Project Database ID (Intelligence Agent)
PROJECT_DATABASE_ID = get_env("INTELLIGENCE_PROJECT_DATABASE_ID")

# LLM Config
GLM_BASE_URL = get_env("INTELLIGENCE_LLM_BASE_URL", "https://api.z.ai/api/coding/paas/v4/")
GLM_MODEL = get_env("INTELLIGENCE_LLM_MODEL", "glm-5")


path: scripts/git_publisher_service.py
content: #!/usr/bin/env python3
"""
Git Publisher Service - launchd용 백그라운드 서비스

Notion에서 '검토 완료' 상태의 글을 자동으로 감지하고 배포합니다.
launchd를 통해 시스템 부팅 시 자동 시작되고, 프로세스가 종료되면 재시작됩니다.
"""

import sys
import os
import time
import logging
from pathlib import Path
from datetime import datetime

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "submodules" / "intelligence-agent" / "src"))

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('/Users/nabang/Documents/OpenClaw/logs/git-publisher-service.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

try:
    from notion_publisher import NotionPublisher
    from publisher_git import GitPublisher
except ImportError as e:
    logger.error(f"Import error: {e}")
    logger.error(f"PYTHONPATH: {sys.path}")
    sys.exit(1)


def main():
    """메인 서비스 루프"""

    logger.info("=" * 70)
    logger.info("Git Publisher Service 시작")
    logger.info("=" * 70)

    try:
        # 초기화
        notion_pub = NotionPublisher()
        git_pub = GitPublisher(notion_publisher=notion_pub)

        logger.info(f"설정된 확인 간격: 300초 (5분)")
        logger.info(f"블로그 저장소: {git_pub.blog_repo_path}")
        logger.info(f"블로그 URL: {git_pub.blog_url}")

        # 모니터링 시작
        git_pub.monitor_and_publish(interval_seconds=300)

    except KeyboardInterrupt:
        logger.info("\n⏸️ 서비스 중지 (KeyboardInterrupt)")
    except Exception as e:
        logger.error(f"❌ 서비스 에러: {e}", exc_info=True)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/intelligence_pipeline.py
content: #!/usr/bin/env python3
"""
Intelligence Agent - 전체 파이프라인 스크립트 (Async)
"""

import sys
import argparse
import asyncio
from collections import defaultdict
from modules.intelligence.utils import setup_logger
from modules.intelligence.collector import NewsCollector
from modules.intelligence.writer import BlogWriter
from modules.intelligence.notion_publisher import NotionPublisher
from modules.intelligence.selector import ArticleSelector

logger = setup_logger(__name__, "pipeline.log")

async def run_pipeline_async(max_articles: int = 5):
    logger.info("=== Intelligence Pipeline (Async) Started ===")

    try:
        # 1. 뉴스 수집 (Collector)
        # Collector는 동기식이므로 그대로 실행 (추후 비동기화 가능)
        logger.info("[1/4] Collecting news from various sources...")
        collector = NewsCollector()
        raw_articles = collector.fetch_all(max_results_per_source=15)
        
        if not raw_articles:
            logger.info("No new articles found. Terminating pipeline.")
            return
        
        logger.info(f"Total candidates collected: {len(raw_articles)}")

        # 2. AI 기반 기사 선별 (Async Selector)
        logger.info(f"[2/4] AI evaluating and selecting best {max_articles} articles (Async)...")
        selector = ArticleSelector()
        selected_articles = await selector.evaluate_and_select(
            raw_articles, 
            max_articles=max_articles,
            min_score=6  # 품질 관리 (6점 미만 탈락)
        )

        if not selected_articles:
            logger.warning("No articles passed the selection criteria (Score >= 6).")
            return

        # 3. 블로그 글 작성 (Async Writer)
        logger.info(f"[3/4] Writing blog posts for {len(selected_articles)} articles (Parallel)...")
        writer = BlogWriter()
        
        # 병렬 실행
        blog_posts_results = await writer.generate_article_batch(selected_articles)
        
        valid_posts = []
        for res in blog_posts_results:
            if isinstance(res, dict) and 'title' in res:
                valid_posts.append(res)
                logger.info(f"   ✅ Done: {res['title'][:40]}")
            elif isinstance(res, Exception):
                logger.error(f"   ❌ Writing failed: {res}")

        # 4. Notion 저장 (Publisher)
        # Notion API는 순차적으로 호출 (Rate Limit 고려)
        logger.info(f"[4/4] Publishing {len(valid_posts)} posts to Notion...")
        notion_pub = NotionPublisher()
        
        success_count = 0
        for i, post in enumerate(valid_posts, 1):
            try:
                logger.info(f"   ({i}/{len(valid_posts)}) Sending to Notion: {post['title'][:40]}...")
                result = notion_pub.create_article(post)
                if result.get('id'):
                    success_count += 1
                    logger.info(f"   ✅ Success: {result.get('url')}")
            except Exception as e:
                logger.error(f"   ❌ Failed to publish to Notion: {e}")
        
        logger.info(f"=== Pipeline Completed: {success_count}/{len(valid_posts)} articles published successfully ===")

    except Exception as e:
        logger.error(f"Critical Pipeline Error: {e}", exc_info=True)
        raise

def main():
    parser = argparse.ArgumentParser(description='Intelligence Agent Pipeline (Async)')
    parser.add_argument('--max-articles', type=int, default=5, help='최종 생성할 블로그 글 수')
    args = parser.parse_args()

    asyncio.run(run_pipeline_async(max_articles=args.max_articles))

if __name__ == "__main__":
    main()

path: scripts/llm_client.py
content: """
LLM Client Module
OpenAI, GLM 등 LLM API와의 통신을 담당합니다.
"""

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from typing import Dict, List, Optional, Any
from modules.intelligence.config import GLM_API_KEY, GLM_BASE_URL, GLM_MODEL
from modules.intelligence.utils import setup_logger

logger = setup_logger(__name__, "llm_client.log")

class LLMClient:
    """기본 LLM 클라이언트 추상 클래스 (Interface)"""
    def chat(self, system_prompt: str, user_prompt: str) -> str:
        raise NotImplementedError

class GLMClient(LLMClient):
    """ZhipuAI GLM API 클라이언트"""

    def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
        self.[REDACTED_SECRET] or GLM_API_KEY
        self.base_url = base_url or GLM_BASE_URL
        self.model = model or GLM_MODEL
        self.timeout = 300  # 5분

        if not self.[REDACTED_SECRET]"GLM_API_KEY is not set.")

        # Requests Session with Retry
        self.session = requests.Session()
        retries = Retry(
            total=3,
            backoff_factor=1,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["POST"]
        )
        self.session.mount("https://", HTTPAdapter(max_retries=retries))

    def chat(self, system_prompt: str, user_prompt: str) -> str:
        """GLM API 호출"""
        if not self.api_key:
            raise ValueError("API Key가 설정되지 않았습니다.")

        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "Accept-Language": "en-US,en"
        }

        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            "temperature": 0.7,
            "max_tokens": 8000
        }

        url = self.base_url.rstrip('/') + "/chat/completions"

        try:
            response = self.session.post(
                url, headers=headers, json=payload, timeout=self.timeout
            )
            response.raise_for_status()
            data = response.json()
            return data["choices"][0]["message"]["content"]

        except requests.exceptions.Timeout:
            logger.error(f"GLM API Timeout ({self.timeout}s)")
            raise Exception(f"GLM API 요청 타임아웃 ({self.timeout}초 초과)")
        except requests.exceptions.RequestException as e:
            logger.error(f"GLM API Request Failed: {e}")
            if hasattr(e, 'response') and e.response is not None:
                logger.error(f"Response: {e.response.text}")
            raise Exception(f"GLM API 요청 실패: {str(e)}")

# 전역 인스턴스 (필요 시 사용)
# default_client = GLMClient()


path: scripts/llm_client_async.py
content: """
Async LLM Client Module
aiohttp를 사용하여 비동기로 LLM API를 호출합니다.
재시도 로직과 에러 처리 개선
"""

import aiohttp
import asyncio
import random
from typing import Dict, Optional, Any
from modules.intelligence.config import GLM_API_KEY, GLM_BASE_URL, GLM_MODEL
from modules.intelligence.utils import setup_logger

logger = setup_logger(__name__, "llm_client_async.log")

class AsyncLLMClient:
    """비동기 GLM API 클라이언트 (재시도 로직 포함)"""

    # 재시도 설정
    MAX_RETRIES = 3
    BASE_DELAY = 2  # 초
    MAX_DELAY = 30  # 초

    def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
        self.[REDACTED_SECRET] or GLM_API_KEY
        self.base_url = base_url or GLM_BASE_URL
        self.model = model or GLM_MODEL
        self.timeout = 300  # 5분

        if not self.[REDACTED_SECRET]"GLM_API_KEY is not set.")

    def _get_url(self) -> str:
        """API URL 생성 (중복 경로 방지)"""
        base = self.base_url.rstrip('/')
        # 이미 /chat/completions가 포함되어 있으면 그대로 사용
        if base.endswith('/chat/completions'):
            return base
        # 아니면 추가
        return f"{base}/chat/completions"

    async def _exponential_backoff(self, attempt: int) -> float:
        """Exponential backoff 계산"""
        delay = min(self.BASE_DELAY * (2 ** attempt) + random.uniform(0, 1), self.MAX_DELAY)
        return delay

    async def chat(self, system_prompt: str, user_prompt: str) -> str:
        """GLM API 비동기 호출 (재시도 로직 포함)"""
        if not self.api_key:
            raise ValueError("API Key가 설정되지 않았습니다.")

        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "Accept-Language": "en-US,en"
        }

        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            "temperature": 0.7,
            "max_tokens": 8000
        }

        url = self._get_url()
        last_error = None

        for attempt in range(self.MAX_RETRIES):
            async with aiohttp.ClientSession() as session:
                try:
                    logger.debug(f"GLM API 호출 시도 {attempt + 1}/{self.MAX_RETRIES}: {url}")

                    async with session.post(url, headers=headers, json=payload, timeout=self.timeout) as response:
                        if response.status == 200:
                            data = await response.json()
                            content = data["choices"][0]["message"]["content"]
                            logger.debug(f"GLM API 성공: {len(content)}자 응답")
                            return content

                        # 429 (Rate Limit) 처리
                        if response.status == 429:
                            error_text = await response.text()
                            logger.warning(f"GLM API Rate Limit (429): {error_text}")

                            if attempt < self.MAX_RETRIES - 1:
                                delay = await self._exponential_backoff(attempt)
                                logger.info(f"Rate limit - {delay:.1f}초 후 재시도...")
                                await asyncio.sleep(delay)
                                continue
                            else:
                                raise Exception(f"GLM API Rate Limit 초과 (재시도 {self.MAX_RETRIES}회 실패)")

                        # 기타 에러
                        error_text = await response.text()
                        logger.error(f"GLM API Error {response.status}: {error_text}")

                        # 5xx 서버 에러는 재시도
                        if 500 <= response.status < 600 and attempt < self.MAX_RETRIES - 1:
                            delay = await self._exponential_backoff(attempt)
                            logger.info(f"서버 에러 - {delay:.1f}초 후 재시도...")
                            await asyncio.sleep(delay)
                            continue

                        response.raise_for_status()

                except asyncio.TimeoutError:
                    logger.warning(f"GLM API Timeout ({self.timeout}s) - 시도 {attempt + 1}/{self.MAX_RETRIES}")
                    last_error = Exception(f"GLM API 요청 타임아웃 ({self.timeout}초 초과)")

                    if attempt < self.MAX_RETRIES - 1:
                        delay = await self._exponential_backoff(attempt)
                        logger.info(f"타임아웃 - {delay:.1f}초 후 재시도...")
                        await asyncio.sleep(delay)
                        continue

                except aiohttp.ClientError as e:
                    logger.warning(f"GLM API 네트워크 에러: {e} - 시도 {attempt + 1}/{self.MAX_RETRIES}")
                    last_error = e

                    if attempt < self.MAX_RETRIES - 1:
                        delay = await self._exponential_backoff(attempt)
                        logger.info(f"네트워크 에러 - {delay:.1f}초 후 재시도...")
                        await asyncio.sleep(delay)
                        continue

                except Exception as e:
                    logger.error(f"GLM API Request Failed: {e}")
                    last_error = e
                    break

        # 모든 재시도 실패
        raise last_error or Exception("GLM API 호출 실패")

    async def health_check(self) -> Dict[str, Any]:
        """API 상태 확인"""
        url = self._get_url()
        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(
                    url,
                    headers={
                        "Authorization": f"Bearer {self.api_key}",
                        "Content-Type": "application/json"
                    },
                    json={
                        "model": self.model,
                        "messages": [{"role": "user", "content": "test"}],
                        "max_tokens": 5
                    },
                    timeout=10
                ) as response:
                    return {
                        "status": "ok" if response.status in [200, 429] else "error",
                        "status_code": response.status,
                        "url": url
                    }
        except Exception as e:
            return {
                "status": "error",
                "error": str(e),
                "url": url
            }

# 동기 호환성을 위한 래퍼 (필요 시 사용)
# client = AsyncLLMClient()
# result = asyncio.run(client.chat(...))


path: scripts/models.py
content: """
Data Models
Pydantic을 사용한 데이터 검증 및 파싱 모델
"""

from pydantic import BaseModel, Field
from typing import List, Optional

class EvaluationItem(BaseModel):
    id: int = Field(..., description="기사 ID")
    score: int = Field(..., ge=1, le=10, description="평가 점수 (1-10)")
    reason: str = Field(..., description="선별 이유")

class EvaluationResponse(BaseModel):
    evaluations: List[EvaluationItem]

class BlogPost(BaseModel):
    title: str = Field(..., description="블로그 글 제목")
    summary: str = Field(..., description="핵심 요약")
    content: str = Field(..., description="마크다운 본문")
    tags: List[str] = Field(default_factory=list, description="태그 목록")
    category: str = Field(..., description="카테고리")


path: scripts/notion_publisher.py
content: """
Notion Publisher
Notion API를 사용하여 블로그 글을 관리합니다.
"""

import requests
import subprocess
import tempfile
import base64
from pathlib import Path
from typing import Dict, Optional, List
from datetime import datetime
from modules.intelligence.config import NOTION_API_KEY, NOTION_DATABASE_ID, BLOG_REPO_PATH, BLOG_URL
from modules.intelligence.utils import setup_logger

logger = setup_logger(__name__, "notion_publisher.log")

class NotionPublisher:
    """Notion Publisher"""

    def __init__(self, [REDACTED_SECRET] = None, database_id: Optional[str] = None,
                 blog_repo_path: Path = None, blog_url: str = None):
        """초기화"""
        self.[REDACTED_SECRET] or NOTION_API_KEY
        self.database_id = database_id or NOTION_DATABASE_ID
        self.version = "2022-06-28"
        self.blog_repo_path = blog_repo_path or BLOG_REPO_PATH
        self.blog_url = blog_url or BLOG_URL

        if not self.api_key:
            raise ValueError("NOTION_API_KEY is not set.")
        if not self.database_id:
            raise ValueError("NOTION_DATABASE_ID is not set.")

        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Bearer {self.api_key}",
            "Notion-Version": self.version,
            "Content-Type": "application/json"
        })

    def _request(self, method: str, path: str, data: Optional[Dict] = None) -> Dict:
        """API 요청 헬퍼"""
        url = f"https://api.notion.com/v1{path}"
        try:
            response = self.session.request(method, url, json=data, timeout=30)
            response.raise_for_status()
            return response.json()
        except requests.exceptions.RequestException as e:
            logger.error(f"Notion API Failed ({method} {path}): {e}")
            if hasattr(e, 'response') and e.response is not None:
                logger.error(f"Response: {e.response.text}")
            # 에러 발생 시 빈 딕셔너리 반환하거나 예외 전파
            raise

    def _convert_mermaid_to_image(self, mermaid_code: str, mermaid_index: int,
                                  output_format: str = 'svg') -> Optional[Dict]:
        """
        Mermaid 코드를 이미지로 변환하여 Hugo 블로그에 저장
        반환값: {'local_path': 로컬 경로, 'url': 블로그 URL}
        """
        try:
            # 이미지 저장 디렉토리 생성
            images_dir = self.blog_repo_path / "static" / "images" / "mermaid"
            images_dir.mkdir(parents=True, exist_ok=True)

            # 고유한 파일명 생성 (타임스탬프 + 인덱스)
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"mermaid_{timestamp}_{mermaid_index}.{output_format}"
            output_file = images_dir / filename

            # Mermaid 코드를 임시 파일로 저장
            with tempfile.NamedTemporaryFile(mode='w', suffix='.mmd', delete=False) as f:
                f.write(mermaid_code)
                mmd_file = f.name

            try:
                # Mermaid CLI 실행
                result = subprocess.run([
                    'npx', '-y', '@mermaid-js/mermaid-cli',
                    '-i', mmd_file,
                    '-o', str(output_file),
                    '-b', 'transparent',
                    '-s', '2'  # scale
                ], capture_output=True, timeout=30, text=True)

                if result.returncode != 0:
                    logger.error(f"Mermaid CLI failed: {result.stderr}")
                    return None

                # 파일이 생성되었는지 확인
                if not output_file.exists():
                    logger.error(f"Image file not created: {output_file}")
                    return None

                # Hugo 내부 경로와 외부 URL 생성
                local_path = f"/images/mermaid/{filename}"
                blog_image_url = f"{self.blog_url.rstrip('/')}/images/mermaid/{filename}"

                logger.info(f"Mermaid diagram saved: {output_file} ({output_file.stat().st_size} bytes)")

                return {
                    'local_path': local_path,
                    'url': blog_image_url,
                    'file_path': str(output_file)
                }

            finally:
                # 임시 Mermaid 파일 삭제
                try:
                    Path(mmd_file).unlink(missing_ok=True)
                except:
                    pass

        except subprocess.TimeoutExpired:
            logger.error("Mermaid CLI timeout")
            return None
        except Exception as e:
            logger.error(f"Failed to convert Mermaid to image: {e}")
            return None

    def create_article(self, article_data: Dict) -> Dict:
        """새 글 생성 (상태: 초안 작성중 -> 검토중)"""
        title = article_data.get('title', 'N/A')
        summary = article_data.get('summary', '')
        
        logger.info(f"Creating Notion page: {title}")

        # Properties
        properties = {
            "내용": {"title": [{"text": {"content": title}}]},
            "URL": {"url": article_data.get('original_url')},
            "상태": {"status": {"name": "초안 작성중"}},
        }

        if article_data.get('category'):
            properties["카테고리"] = {"select": {"name": article_data.get('category')}}

        if article_data.get('tags'):
            properties["테그"] = {"multi_select": [{"name": tag} for tag in article_data.get('tags')[:5]]}

        # Blocks (Content)
        children = self._convert_to_blocks(article_data.get('content', summary))

        # 1. Create Page
        payload = {
            "parent": {"database_id": self.database_id},
            "properties": properties
        }

        try:
            page = self._request("POST", "/pages", payload)
            page_id = page['id']
            
            # 2. Append Blocks (Batch processing)
            if children:
                self._append_children(page_id, children)

            # 3. Update Status
            self.update_status(page_id, "검토중")
            
            return {"id": page_id, "url": page.get('url')}

        except Exception as e:
            logger.error(f"Failed to create article: {e}")
            return {}

    def _convert_to_blocks(self, content: str) -> List[Dict]:
        """Markdown 텍스트를 Notion 블록으로 변환 (개선)"""
        # 이스케이프 문자 처리
        content = content.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\')

        blocks = []
        lines = content.split('\n')
        i = 0
        mermaid_index = 0  # Mermaid 다이어그램 인덱스
        current_paragraph = []

        def flush_paragraph():
            if current_paragraph:
                text = ' '.join(current_paragraph)
                blocks.append(self._create_paragraph_block(text))
                current_paragraph.clear()

        while i < len(lines):
            line = lines[i].rstrip()

            # 빈 줄: 문단 플러시
            if not line:
                flush_paragraph()
                i += 1
                continue

            # 코드 블록 시작
            if line.startswith('```'):
                flush_paragraph()
                lang = line[3:].strip() or "plain text"

                # 먼저 코드 라인들을 수집
                i += 1
                code_lines = []
                while i < len(lines) and not lines[i].startswith('```'):
                    code_lines.append(lines[i])
                    i += 1

                # 코드 텍스트 생성
                code_text = '\n'.join(code_lines)

                # Mermaid는 Notion에서 코드 블록으로 저장 (Hugo에서 렌더링)
                if lang.lower() == "mermaid":
                    mermaid_index += 1

                    # Notion에는 코드 블록으로 저장 (안내 메시지 없음)
                    blocks.append({
                        "object": "block",
                        "type": "code",
                        "code": {
                            "rich_text": [{"type": "text", "text": {"content": code_text}}],
                            "language": "javascript"  # Notion은 mermaid 언어를 지원하지만 렌더링 안 됨
                        }
                    })

                    i += 1  # closing ``` skip
                    continue

                # Notion 지원 언어로 매핑
                notion_langs = {
                    "python": "python", "javascript": "javascript", "js": "javascript",
                    "typescript": "typescript", "ts": "typescript",
                    "java": "java", "c": "c", "cpp": "c++", "c++": "c++",
                    "c#": "c#", "go": "go", "rust": "rust", "ruby": "ruby",
                    "php": "php", "swift": "swift", "kotlin": "kotlin",
                    "scala": "scala", "shell": "shell", "bash": "bash",
                    "sql": "sql", "html": "html", "css": "css",
                    "json": "json", "yaml": "yaml", "xml": "xml",
                    "markdown": "markdown", "md": "markdown",
                    "dart": "dart", "lua": "lua"
                }

                lang = notion_langs.get(lang.lower(), "plain text")

                # Notion code block 생성 (2000자 제한 처리, 문장 단위 분할)
                max_code_length = 2000

                if len(code_text) <= max_code_length:
                    # 코드가 2000자 이내이면 한 블록으로 생성
                    blocks.append({
                        "object": "block",
                        "type": "code",
                        "code": {
                            "rich_text": [{"type": "text", "text": {"content": code_text}}],
                            "language": lang
                        }
                    })
                else:
                    # 코드가 2000자를 초과하면 문장/문단 단위로 분할
                    logger.warning(f"Code block too long ({len(code_text)} chars), splitting at natural boundaries")
                    chunks = self._split_code_at_boundaries(code_text, max_code_length)
                    for chunk in chunks:
                        blocks.append({
                            "object": "block",
                            "type": "code",
                            "code": {
                                "rich_text": [{"type": "text", "text": {"content": chunk}}],
                                "language": lang
                            }
                        })

                i += 1  # closing ``` skip
                continue

            # 헤딩
            if line.startswith('# '):
                flush_paragraph()
                blocks.append({
                    "object": "block", "type": "heading_1",
                    "heading_1": {"rich_text": [{"text": {"content": line[2:].strip()}}]}
                })
            elif line.startswith('## '):
                flush_paragraph()
                blocks.append({
                    "object": "block", "type": "heading_2",
                    "heading_2": {"rich_text": [{"text": {"content": line[3:].strip()}}]}
                })
            elif line.startswith('### '):
                flush_paragraph()
                blocks.append({
                    "object": "block", "type": "heading_3",
                    "heading_3": {"rich_text": [{"text": {"content": line[4:].strip()}}]}
                })
            # 불릿 리스트
            elif line.startswith('- ') or line.startswith('* '):
                flush_paragraph()
                blocks.append({
                    "object": "block", "type": "bulleted_list_item",
                    "bulleted_list_item": {"rich_text": [{"text": {"content": line[2:].strip()}}]}
                })
            # 숫자 리스트
            elif line[0].isdigit() and line.startswith('. '):
                flush_paragraph()
                blocks.append({
                    "object": "block", "type": "numbered_list_item",
                    "numbered_list_item": {"rich_text": [{"text": {"content": line[2:].strip()}}]}
                })
            # 인용문
            elif line.startswith('> '):
                flush_paragraph()
                blocks.append({
                    "object": "block", "type": "quote",
                    "quote": {"rich_text": [{"text": {"content": line[2:].strip()}}]}
                })
            # 일반 텍스트 (문단에 추가)
            else:
                current_paragraph.append(line)

            i += 1

        flush_paragraph()
        return blocks

    def _create_paragraph_block(self, text: str) -> Dict:
        return {
            "object": "block",
            "type": "paragraph",
            "paragraph": {
                "rich_text": [{"type": "text", "text": {"content": text[:2000]}}]
            }
        }

    def _append_children(self, block_id: str, children: List[Dict]):
        """블록 자식 추가 (배치 처리)"""
        batch_size = 50
        for i in range(0, len(children), batch_size):
            batch = children[i:i + batch_size]
            try:
                self._request("PATCH", f"/blocks/{block_id}/children", {"children": batch})
            except Exception as e:
                logger.error(f"Failed to append blocks batch {i}: {e}")

    def update_status(self, page_id: str, status: str) -> bool:
        """상태 업데이트"""
        try:
            self._request("PATCH", f"/pages/{page_id}", {
                "properties": {"상태": {"status": {"name": status}}}
            })
            return True
        except Exception:
            return False

    def get_review_done_articles(self) -> List[Dict]:
        """검토 완료 글 조회"""
        return self._query_database({"property": "상태", "status": {"equals": "검토 완료"}})
        
    def _query_database(self, filter_condition: Dict) -> List[Dict]:
        """데이터베이스 조회"""
        try:
            res = self._request("POST", f"/databases/{self.database_id}/query", {
                "filter": filter_condition
            })
            return res.get('results', [])
        except Exception:
            return []

    def update_published_url(self, page_id: str, url: str):
        """배포 URL 업데이트"""
        try:
            self._request("PATCH", f"/pages/{page_id}", {
                "properties": {"배포 URL": {"url": url}}
            })
        except Exception as e:
            logger.error(f"Failed to update published URL: {e}")

    def get_page_content(self, page_id: str) -> str:
        """페이지 본문 가져오기 (Markdown 변환)"""
        try:
            res = self._request("GET", f"/blocks/{page_id}/children")
            blocks = res.get('results', [])
            return "\n\n".join([self._block_to_text(b) for b in blocks])
        except Exception:
            return ""

    def _block_to_text(self, block: Dict) -> str:
        """블록 -> 마크다운 텍스트 변환 (개선)"""
        btype = block.get('type')
        if btype not in block:
            return ""

        # 코드 블록 처리
        if btype == 'code':
            code_data = block['code']
            language = code_data.get('language', 'plain text')

            # 코드 내용 추출
            lines = [t.get('text', {}).get('content', '') for t in code_data.get('rich_text', [])]
            code_content = ''.join(lines)

            # Javascript 코드 블록이지만 Mermaid 다이어그램인지 확인
            # Mermaid 키워드로 시작하면 Mermaid로 처리

            # 언어 확인 (대소문자 구분 없이)
            lang_lower = language.lower() if isinstance(language, str) else ''

            # 코드 내용에서 선행 공백 제거
            stripped_content = code_content.strip()

            # Mermaid 키워드 목록 (확장)
            mermaid_keywords = [
                # 다이어그램 타입
                'graph', 'flowchart', 'sequenceDiagram', 'classDiagram',
                'stateDiagram', 'stateDiagram-v2', 'entityRelationshipDiagram', 'userJourney',
                'gantt', 'pie', 'mindmap', 'timeline', 'gitgraph', 'erDiagram', 'journey',
                'pieChart', 'requirementDiagram', 'git',
                # 서브그래프 및 구조
                'subgraph', 'end[', 'end;',
                # 노드 연결 패턴 (-->, --->, -.-, ==>)
            ]

            # Mermaid 문법 패턴 확인 (전체 내용 스캔)
            is_mermaid = False

            if lang_lower == 'javascript':
                # 첫 라인 확인
                first_line = stripped_content.split('\n')[0].strip() if stripped_content else ''

                # 1. 첫 라인이 Mermaid 키워드로 시작하는지 확인
                if any(first_line.startswith(keyword) or first_line.startswith(keyword + ' ')
                       for keyword in mermaid_keywords):
                    is_mermaid = True

                # 2. 전체 내용에서 Mermaid 패턴 확인 (서브그래프, 화살표 등)
                if not is_mermaid:
                    # 서브그래프 패턴: subgraph ...
                    if 'subgraph' in stripped_content.lower():
                        is_mermaid = True
                    # 화살표 패턴: -->, --->, -.-, ==>
                    elif re.search(r'\w+\s*--?>\s*\w+', stripped_content):
                        is_mermaid = True
                    # 노드 패턴: Node1[Label], Node2(Label), Node3{Label}
                    elif re.search(r'\w+\[[^\]]+\]', stripped_content) or \
                         re.search(r'\w+\([^)]+\)', stripped_content) or \
                         re.search(r'\w+\{[^}]+\}', stripped_content):
                        is_mermaid = True

            if is_mermaid:
                logger.info(f"🔄 Converting javascript to mermaid (first_line: {stripped_content[:50]})")
                return f"```mermaid\n{code_content}\n```"

            # Notion 언어 → 마크다운 언어 매핑
            lang_map = {
                'plain text': 'text',
                'python': 'python',
                'javascript': 'javascript',
                'typescript': 'typescript',
                'java': 'java',
                'c': 'c',
                'c++': 'cpp',
                'c#': 'csharp',
                'go': 'go',
                'rust': 'rust',
                'ruby': 'ruby',
                'php': 'php',
                'swift': 'swift',
                'kotlin': 'kotlin',
                'scala': 'scala',
                'shell': 'bash',
                'bash': 'bash',
                'sql': 'sql',
                'html': 'html',
                'css': 'css',
                'json': 'json',
                'yaml': 'yaml',
                'xml': 'xml',
                'markdown': 'markdown',
                'mermaid': 'mermaid'  # Notion mermaid → markdown mermaid
            }
            md_lang = lang_map.get(language, 'text')
            return f"```{md_lang}\n{code_content}\n```"

        # 이미지 블록 처리 (Mermaid 다이어그램 이미지)
        if btype == 'image':
            image_data = block.get('image', {})
            image_type = image_data.get('type', '')

            if image_type == 'external':
                url = image_data.get('external', {}).get('url', '')
                if url:
                    # 이미지가 있는 경우 마크다운 이미지로 변환
                    # 캡션이나 alt 텍스트가 있다면 추가
                    return f"\n![Mermaid Diagram]({url})\n"
            elif image_type == 'file':
                url = image_data.get('file', {}).get('url', '')
                if url:
                    return f"\n![Mermaid Diagram]({url})\n"

            return ""  # 이미지 처리 실패

        # 테이블 처리
        if btype == 'table':
            return self._convert_table_to_markdown(block['table'])

        # 리치 텍스트 기반 블록 처리
        if 'rich_text' in block[btype]:
            texts = [t.get('text', {}).get('content', '') for t in block[btype]['rich_text']]
            text = "".join(texts)

            if btype == 'paragraph':
                # Mermaid 안내 문구 필터링
                if 'Mermaid 다이어그램' in text and '블로그에서 자동으로 다이어그램으로 렌더링됩니다' in text:
                    return ''  # 안내 문구는 제거
                return text
            if btype == 'heading_1':
                return f"# {text}"
            if btype == 'heading_2':
                return f"## {text}"
            if btype == 'heading_3':
                return f"### {text}"
            if btype == 'bulleted_list_item':
                return f"- {text}"
            if btype == 'numbered_list_item':
                return f"1. {text}"
            if btype == 'to_do':
                checked = block[btype].get('checked', False)
                checkbox = '- [x]' if checked else '- [ ]'
                return f"{checkbox} {text}"
            if btype == 'quote':
                return f"> {text}"
            if btype == 'callout':
                icon = block[btype].get('icon', {}).get('emoji', '💡')
                return f"> {icon} {text}"
            if btype == 'divider':
                return "---"
            if btype == 'toggle':
                return f"<details>\n<summary>{text}</summary>\n\n"
            if btype == 'synct_atomic_block':
                return f"```{text}\n```"

        return ""

    def _convert_table_to_markdown(self, table_data: Dict) -> str:
        """Notion 테이블을 마크다운 테이블로 변환"""
        try:
            rows = table_data.get('table_row', [])
            if not rows:
                return ""

            markdown_rows = []
            for i, row_block in enumerate(rows):
                if 'table_row' not in row_block:
                    continue
                cells = row_block['table_row'].get('cells', [])
                cell_texts = []
                for cell in cells:
                    cell_text = ''.join([t.get('text', {}).get('content', '') for t in cell])
                    cell_texts.append(cell_text.strip())

                # 첫 번째 행은 헤더로 처리
                if i == 0:
                    header = "| " + " | ".join(cell_texts) + " |"
                    separator = "|" + "|".join([" --- " for _ in cell_texts]) + "|"
                    markdown_rows.append(header)
                    markdown_rows.append(separator)
                else:
                    row = "| " + " | ".join(cell_texts) + " |"
                    markdown_rows.append(row)

            return "\n".join(markdown_rows)
        except Exception as e:
            logger.error(f"Failed to convert table to markdown: {e}")
            return ""

    def _split_code_at_boundaries(self, code_text: str, max_length: int) -> List[str]:
        """코드를 자연스러운 경계에서 분할 (줄바꿈, 쉼표, 마침표 기준)"""
        if len(code_text) <= max_length:
            return [code_text]

        chunks = []
        remaining = code_text

        while len(remaining) > max_length:
            # 현재 청크 크기
            current_chunk = remaining[:max_length]

            # 거꾸로 탐색하여 적절한 분리점 찾기
            # 우선순위: 1) 빈 줄(\n\n) 2) 쉼표(;) 3) 마침표(.) 4) 줄바꿈(\n)
            split_pos = -1

            # 1. 빈 줄(연속된 두 줄바꿈) 찾기 - 함수/클래스 구분
            last_double_newline = current_chunk.rfind('\n\n')
            if last_double_newline > max_length * 0.8:  # 최소 80%는 확보
                split_pos = last_double_newline + 2
            else:
                # 2. 쉼표(;) 찾기 - 문장 완성 기준
                last_semicolon = current_chunk.rfind(';')
                if last_semicolon > max_length * 0.85:
                    split_pos = last_semicolon + 1
                else:
                    # 3. 마침표(.) 찾기 (단, 콜론(:), 느낌표(!) 제외)
                    for punct in ['.', ':', '!']:
                        last_punct = current_chunk.rfind(punct)
                        # 다음 문자가 알파벳이나 공백인지 확인하여 실제 문장 끝인지 체크
                        if last_punct > max_length * 0.85:
                            next_char_idx = last_punct + 1
                            if next_char_idx < len(current_chunk):
                                next_char = current_chunk[next_char_idx]
                                if next_char.isalnum() or next_char.isspace():
                                    split_pos = last_punct + 1
                                    break

            # 4. 줄바꿈(\n) 찾기
            if split_pos == -1:
                last_newline = current_chunk.rfind('\n')
                if last_newline > max_length * 0.9:
                    split_pos = last_newline + 1

            # 적절한 분리점을 찾지 못하면 강제로 max_length에서 분할
            if split_pos == -1:
                split_pos = max_length

            chunks.append(remaining[:split_pos])
            remaining = remaining[split_pos:]

        # 남은 부분 추가
        if remaining:
            chunks.append(remaining)

        logger.info(f"Code split into {len(chunks)} chunks (original: {len(code_text)} chars)")
        return chunks

if __name__ == "__main__":
    pub = NotionPublisher()
    # print(pub.get_review_done_articles())

path: scripts/prompt_manager.py
content: """
Prompt Manager
YAML 파일에서 프롬프트를 로드하고 관리합니다.
"""

import yaml
from pathlib import Path
from typing import Any, Dict

# 프로젝트 내의 prompts.yaml 경로 설정
PROMPTS_PATH = Path(__file__).resolve().parent / "prompts.yaml"

class PromptManager:
    _prompts: Dict[str, Any] = {}

    @classmethod
    def load(cls):
        """YAML 파일 로드"""
        if not PROMPTS_PATH.exists():
            raise FileNotFoundError(f"Prompts file not found at {PROMPTS_PATH}")
        
        with open(PROMPTS_PATH, 'r', encoding='utf-8') as f:
            cls._prompts = yaml.safe_load(f)

    @classmethod
    def get(cls, key: str, **kwargs) -> str:
        """
        키(예: 'writer.system')에 해당하는 프롬프트를 가져와 포맷팅

        Args:
            key: 점(.)으로 구분된 키 (예: 'writer.base_system')
            **kwargs: 포맷팅에 사용할 변수들

        Returns:
            포맷팅된 프롬프트 문자열
        """
        if not cls._prompts:
            cls.load()

        keys = key.split('.')
        value = cls._prompts
        
        try:
            for k in keys:
                value = value[k]
        except (KeyError, TypeError):
            raise KeyError(f"Prompt key '{key}' not found.")

        if isinstance(value, str):
            return value.format(**kwargs)
        return value

    @classmethod
    def get_raw(cls, key: str) -> Any:
        """포맷팅 없이 원본 값(dict 등) 반환"""
        if not cls._prompts:
            cls.load()

        keys = key.split('.')
        value = cls._prompts
        
        try:
            for k in keys:
                value = value[k]
            return value
        except (KeyError, TypeError):
            return None

# 초기화 시 로드
PromptManager.load()


path: scripts/prompts.yaml
content: # 프롬프트 템플릿 관리

selector:
  system: |
    당신은 {category} 분야의 전문 기술 블로그 편집장입니다.
    제시된 기사들을 분석하여 블로그 글로 작성하기에 가장 가치 있는 기사를 선별해주세요.

    **평가 기준**:
    1. **기술적 깊이 (Technical Depth)**: 단순 뉴스가 아닌 기술적 분석이나 코드, 원리가 포함되어 있는가?
    2. **영향력 (Impact)**: 해당 분야의 전문가나 실무자에게 얼마나 중요한 정보인가?
    3. **독창성 (Originality)**: 흔한 주제가 아닌 새롭거나 흥미로운 관점을 제공하는가?

    **출력 형식 (JSON)**:
    {{
      "evaluations": [
        {{
          "id": 기사 ID (숫자),
          "score": 점수 (1~10),
          "reason": 선별 이유 (한 문장)
        }}
      ]
    }}

  user: |
    다음 {category} 관련 기사들을 평가해주세요:

    {articles_text}

writer:
  base_system: |
    당신은 {name} 페르소나입니다.
    **전문 분야**: {expertise}
    **글쓰기 톤**: {tone}

    ## 📘 블로그 운영 정보
    - **블로그 이름**: "코딩이 싫은 거북이"
    - **타겟 독자**: 개발자, 보안 엔지니어, 연구원, 기술 실무자
    - **글쓰기 목적**: 실무에 바로 적용할 수 있는 깊이 있는 기술 지식 공유

    ## ✨ 글 작성 원칙

    ### 1. 구조 (Structure)
    - **명확한 서론-본론-결론 구조**
    - 소제목(##, ###)을 활용한 논리적 흐름
    - 독자가 쉽게 스캔할 수 있는 서식

    ### 2. 전문성 (Expertise)
    - 단순 뉴스 전달이 아닌 **기술적 깊이** 있는 분석
    - 원본 기사의 재해석이 아닌 **전문가 관점의 인사이트** 추가
    - 관련 배경지식, 맥락 설명

    ### 3. 실용성 (Practicality)
    - **코드 예시 필수** (복붙해서 바로 실행 가능)
    - 실무에서 바로 적용 가능한 가이드
    - Before/After 비교, 설정 예시

    ### 4. 시각화 (Visualization)
    - **Mermaid 다이어그램 적극 활용** (아키텍처, 흐름도)
    - 표를 활용한 성능/기능 비교
    - 코드 블록에 문법 강조 (```python, ```yaml 등)

    ### 5. SEO 및 참고자료
    - 매력적인 제목 (관련 키워드 포함)
    - arXiv, GitHub, 공식 문서 링크
    - 관련 논문, CVE 번호 인용

    ### 6. 길이 및 형식
    - **최소 2000자 이상** (서론 10% + 본론 75% + 결론 15%)
    - Markdown 형식 strictly 준수
    - 문단 간 적절한 여백 (빈 줄)

    ## ⚠️ 금지 사항
    - ❌ "이 글에서는 ~에 대해 알아보겠습니다" 같은 진부한 도입
    - ❌ 단순 번역 또는 요약
    - ❌ 코드 없는 개념만 나열
    - ❌ "다음 시간에..." 같은 클리프헹거

    {persona_specific}

  user: |
    다음 기사를 바탕으로 전문적인 블로그 글을 작성해주세요.

    === 원본 기사 ===
    **출처**: {source}
    **제목**: {title}
    **URL**: {url}
    **요약**: {summary}

    === 작성 가이드라인 ===

    ### 1. 제목 (Title)
    - 30-50자 사이의 매력적인 제목
    - 기술 키워드 포함 + 관심 유발
    - 예: "🔒 CVE-2024-XXXX: Apache Struts2 RCE 취약점 분석과 대응"

    ### 2. 요약 (Summary)
    - 3-4문장으로 전체 내용 핵심 요약
    - 무엇을, 왜, 어떻게를 포함
    - 독자가 "이거 당장 써봐야겠다"라고 느끼게

    ### 3. 본문 (Content) - **최소 2000자 이상**

    #### 3.1 서론 (10%)
    - 실제 문제나 시나리오로 시작 (hook)
    - 왜 이 주제가 중요한지 motivation
    - 이 글에서 다룰 내용 preview

    #### 3.2 본론 (75%)
    **단순 설명 금지. 다음 요소 필수 포함:**

    a. **기술적 배경/원리**
       - 관련 기술, 선행 연구, 배경지식
       - 왜 이 문제가 발생했는지

    b. **핵심 내용 상세 설명**
       - 아키텍처 다이어그램 (Mermaid)
       - 단계별 설명
       - 기술적 깊이 (수식, 알고리즘, 프로토콜 등)

    c. **코드/구현 예시**
       - 실제 실행 가능한 코드
       - Before/After 비교
       - 설정 파일 (YAML, JSON 등)

    d. **실무 적용 가이드**
       - Step 1, 2, 3... 구체적 단계
       - 주의사항, 베스트 프랙티스
       - 트러블슈팅 팁

    e. **비교/분석**
       - 표를 활용한 대안 비교
       - 성능 수치 (있으면)

    #### 3.3 결론 (15%)
    - 핵심 요약
    - 전문가의 인사이트 & 예측
    - 실무 적용 조언
    - 참고자료 (링크)

    ### 4. 태그 (Tags)
    - 관련 기술 키워드 3-5개
    - 대중적인 태그 + 구체적인 태그 섞기

    === 필수 포함 요소 체크리스트 ===
    [ ] Mermaid 다이어그램 (최소 1개)
    [ ] 코드 예시 (최소 1개, 실행 가능)
    [ ] 표 또는 리스트로 정리 (최소 1개)
    [ ] 참고 링크 (arXiv, GitHub, 공식 문서 등)
    [ ] 실무 적용 가이드 (Step by step)

    === 출력 형식 (JSON) ===
    {{
      "title": "매력적인 제목 (30-50자)",
      "summary": "3-4문장 핵심 요약",
      "content": "마크다운 형식 본문 (2000자 이상)\n\n## 서론\n\n...\n\n## 본론\n\n...\n\n## 결론\n\n...",
      "tags": ["태그1", "태그2", "태그3"],
      "category": "카테고리"
    }}

# 2단계 분리 프롬프트 (Step 1: 메타데이터)
writer_metadata:
  base_system: |
    당신은 {name} 페르소나입니다.
    **전문 분야**: {expertise}
    **글쓰기 톤**: {tone}

    ## 📋 작업: 블로그 메타데이터 생성

    당신은 원본 기사를 분석하여 **블로그 글의 메타데이터(제목, 요약, 태그)**만 생성합니다.

    ## 🎯 제목 작성 필수 가이드라인

    **기본 원칙**:
    - 한글 위주 작성, 기술 용어는 영어 유지
    - 제목만 보고 **어떤 기술인지 바로 파악 가능해야 함**
    - 30-60자 사이 (너무 길어도 OK, 내용이 명확하면)
    - 이모지 적극 활용 (🔒, 🤖, 🚀, 🛡️, 🔥, 🐳 등)

    **추천 포맷**:
    ```
    [이모지] [핵심 기술(영어)] [문제/효과(한글)]: [구체적 주제]
    ```

    **구체적 예시**:
    - ✅ "🔒 Ingress-Nginx RCE: 쿠버네티스 클러스터 탈취 공격 분석"
    - ✅ "🤖 LangChain 도입 가이드: LLM 에이전트 3가지 패턴 구현"
    - ✅ "🛡️ LiteBox: Rust 기반 보안 OS로 컨테이너 탈출 방어"
    - ✅ "🔧 Kubernetes HPA: CPU/메모리 기반 오토스케일링 설정"
    - ✅ "📚 PyTorch 튜토리얼: Transformer 모델 직접 구현해보기"

    **금지 사항**:
    - ❌ "Microsoft의 새로운 오픈소스 프로젝트" → 어떤 기술인지 모름
    - ❌ "AI 시대의 보안" → 너무 일반적, 내용 파악 불가
    - ❌ "쿠버네티스 보안에 대하여" → 진부한 표현
    - ❌ "최신 기술 트렌드 분석" → 어떤 기술인지 모름

    **카테고리별 제목 예시**:
    - **보안**: 🔒 CVE-2024-XXXX: [기술명] 취약점 분석과 대응
    - **보안**: 🛡️ [기술명]: [보안 효과] 구현 가이드
    - **AI**: 🤖 [기술명]: [문제] 해결을 위한 [방법론]
    - **AI**: 📚 [기술명] 튜토리얼: [구체적 구현]
    - **DevOps**: 🚀 [기술명]: [인프라] 자동화하기
    - **DevOps**: 🔧 [기술명] vs [비교대상]: [기준] 비교
    - **CVE**: 🚨 [CVE번호]: [제품] [공격 유형] PoC 분석

    ## 요약 작성 가이드
    - 3-4문장
    - 무엇(What), 왜(Why), 어떻게(How) 포함
    - 기술적 핵심 명확히 전달
    - 전문 용어는 영어, 설명은 한글

    ## 태그 선정 가이드
    - 3-5개
    - 대중적인 태그(예: AI, Security) + 구체적 태그(예: Transformer, XSS) 혼합
    - 기술명은 영어(예: Kubernetes, RCE, LLM)
    - 한글 태그도 포함(예: 보안, 취약점)

    {persona_specific}

  user: |
    다음 기사를 바탕으로 메타데이터를 생성하세요.

    === 원본 기사 ===
    **출처**: {source}
    **제목**: {title}
    **URL**: {url}
    **요약**: {summary}

    === 제목 작성 체크리스트 ===
    [ ] 한글 위주, 기술 용어는 영어 유지
    [ ] 제목만 보고 어떤 기술인지 바로 파악 가능
    [ ] 구체적 기술명/문제점 포함
    [ ] 이모지로 카테고리 표시

    === 출력 형식 (JSON, JSON만 출력) ===
    {{
      "title": "매력적인 제목 (한글+영어 기술명 혼용, 30-60자)",
      "summary": "3-4문장 핵심 요약",
      "tags": ["태그1", "태그2", "태그3", "태그4"],
      "category": "{category}"
    }}

# 2단계 분리 프롬프트 (Step 2: 본문)
writer_content:
  base_system: |
    당신은 {name} 페르소나입니다.
    **전문 분야**: {expertise}
    **글쓰기 톤**: {tone}

    ## 📝 작업: 블로그 본문 작성

    이미 생성된 메타데이터를 바탕으로 **마크다운 형식의 본문만** 작성합니다.

    ## 본문 구조 (최소 2000자)

    ### 서론 (10%)
    - 실제 문제나 시나리오로 시작 (hook)
    - 진부한 "이 글에서는 ~" 금지
    - 왜 이 주제가 중요한지 motivation

    ### 본론 (75%)
    **필수 포함 요소:**
    - ✅ Mermaid 다이어그램 (최소 1개, **심플한 디자인**)
    - ✅ 코드 예시 (최소 1개, 실행 가능)
    - ✅ 표/비교 (최소 1개)
    - ✅ Step-by-step 가이드
    - ✅ 기술적 깊이 (원리, 메커니즘)

    ### Mermaid 다이어그램 가이드라인
    모든 Mermaid 다이어그램은 **심플하고 깔끔한 디자인**을 유지하세요:

    1. **기본 원칙**: 스타일 속성을 사용하지 마세요
       ```mermaid
       graph TD
           A[Node A] --> B[Node B]
           B --> C[Node C]
       ```

    2. **노드 형태**: 기본 네모형 `[ ]` 만 사용
       - ❌ 서브그래프 내부 스타일 금지
       - ❌ fill, stroke, stroke-width 등의 style 속성 금지
       - ❌ classDef, class 적용 금지

    3. **참고 디자인**: 기존 블로그의 심플한 스타일을 참고
       ```mermaid
       graph LR
           A[Git Push] --> B[CI Build]
           B --> C[Unit Tests]
           C --> D[Docker Build]
       ```

    4. **절대 금지 사항**:
       - style A fill:#f9f,stroke:#333,stroke-width:2px ❌
       - style CoreComponent fill:#bbf,stroke:#333,stroke-width:2px ❌
       - classDef 기본노드 fill:#f9f,stroke:#333 ❌
       - 그 어떤 색상/스타일 적용도 금지 ❌❌❌

    ### 결론 (15%)
    - 핵심 요약
    - 전문가 인사이트
    - 참고자료 링크

    ## ⚠️ 금지 사항
    - ❌ 제목이나 요약 다시 작성 금지 (이미 있음)
    - ❌ JSON 형식 금지 (순수 마크다운만 작성)
    - ❌ "다음 시간에..." 같은 클리프헹거

    {persona_specific}

  user: |
    이미 결정된 메타데이터를 바탕으로 본문을 작성하세요.

    === 메타데이터 ===
    **제목**: {title}
    **요약**: {summary}
    **태그**: {tags}

    === 원본 기사 ===
    **출처**: {source}
    **제목**: {original_title}
    **URL**: {url}
    **요약**: {original_summary}

    === 작성 요구사항 ===

    본문을 **순수 마크다운 형식**으로 작성하세요.

    ### 필수 구조
    1. **서론** (## 서론)
       - 실제 문제로 시작
       - motivation 명확히

    2. **본론** (## 본론 또는 구체적 소제목)
       - 기술적 배경/원리
       - Mermaid 다이어그램 (필수)
       - 코드 예시 (필수)
       - 실무 적용 가이드

    3. **결론** (## 결론)
       - 핵심 요약
       - 전문가 인사이트
       - 참고 링크

    ### ⚠️ 마크다운 형식 필수 준수 사항

    **1. Mermaid 다이어그램**: 반드시 코드 블록으로 감싸야 합니다
    ```markdown
    ```mermaid
    graph TD
        A[Start] --> B[End]
    ```
    ```

    **2. 코드 예시**: 반드시 언어를 지정해야 합니다
    ```markdown
    ```python
    def hello():
        print("Hello")
    ```
    ```

    **3. 표(Table)**: Markdown 표 형식을 반드시 준수해야 합니다
    - 각 행은 반드시 새로운 줄에 작성
    - 헤더 행 다음 구분자 행 필수 (`| --- | --- |`)
    - 셀 정렬: `:---` (좌측), `:---:` (중앙), `---:` (우측)

    예시:
    ```markdown
    | 비교 항목 | 기존 AI 라이팅 툴 | CineGraphs (Qwen2.5-7B FT) |
    | :--- | :--- | :--- |
    | 학습 데이터 | 일반 인터넷 텍스트 | 100편의 고급 영화 구조적 데이터 |
    | 출력 형식 | 선형 텍스트 (Paragraph) | 확률적 분기 그래프 (Graph) |
    | 창의성 지표 | 낮음 (Cliché 위험) | 높음 (비선형적 구조) |
    ```

    ### 출력 형식 (중요!)
    - **JSON 금지**
    - **순수 마크다운만** 출력
    - 첫 줄부터 바로 본문 시작 (## 서론)
    - 2000자 이상

topic_analyzer:
  system: |
    You are a news analysis expert.
    Analyze the provided articles and group them into 3-5 interesting topics.
    Output MUST be in JSON format:
    {{
      "topics": [
        {{
          "topic_title": "Topic Title",
          "description": "Why this is important",
          "related_articles": [0, 2, 5],
          "angle": "Key perspective"
        }}
      ]
    }}

  user: |
    Analyze these articles:
    {articles_text}

personas:
  security:
    name: "보안 전문가"
    expertise: "사이버 보안, 취약점 분석, 웹 해킹, 침투 테스트"
    tone: "전문적이지만 실용적이고 현장 감각 있게"
    category: "보안"
    default_tags: ["Security", "Cybersecurity", "보안", "취약점"]
    tag_keywords: ["XSS", "CSRF", "RCE", "Exploit", "Vulnerability", "Zero-day"]
    specific_prompt: |
      ### 보안 분야 작성 특이사항
      1. **공격 시나리오 중심**: 단순 기술 설명보다 "어떻게 악용되는지" 실제 시나리오 작성
      2. **PoC 코드 필수**: 개념 증명 코드 포함 (학습용, ethical hacking 강조)
      3. **시각화 필수**: Mermaid로 공격 흐름도, 네트워크 topology 작성
         **Mermaid 다이어그램 심플 디자인 원칙**:
         - 절대 스타일 속성 사용 금지 (style, fill, stroke 등 ❌)
         - 기본 네모형 노드만 사용: `[Node Name]`
         - 기본 화살표만 사용: `-->`
         - 색상, 굵기, 테두리 등의 시각 효과 제거
      4. **완화 조치 구체화**: "업데이트하세요" 대신 구체 설정값, 코드 스니펫 제공
      5. **윤리적 경고**: 모든 공격 기술 설명 앞에 "방어 목적"임 명시

  ai_ml:
    name: "AI/ML 연구자"
    expertise: "딥러닝, NLP, LLM, MLOps, Transformer, 생성형 AI"
    tone: "학술적이지만 이해하기 쉽고, 최신 논문 기반"
    category: "AI"
    default_tags: ["AI", "LLM", "Deep Learning", "Machine Learning"]
    tag_keywords: ["Transformer", "GPT", "Fine-tuning", "RAG", "Diffusion", "Prompt Engineering"]
    specific_prompt: |
      ### AI/ML 분야 작성 특이사항
      1. **최신 논문 기반**: arXiv, top conference 논문 인용
      2. **기술적 깊이**: 단순 뉴스가 아닌 원리, 알고리즘 설명
      3. **코드 중심**: PyTorch/TensorFlow 스크립트로 실제 구현 보여주기
      4. **시각화**:
         - Mermaid로 모델 architecture (심플한 디자인)
         - 표로 성능 비교
         - 그래프 설명 (텍스트로 대체)
         **Mermaid 다이어그램 심플 디자인 원칙**:
         - 절대 스타일 속성 사용 금지
         - 기본 네모형 노드만 사용
      5. **실무 관점**: MLOps, 서빙, 최적화 팁 포함

  devops:
    name: "DevOps/SRE 엔지니어"
    expertise: "CI/CD, Kubernetes, Docker, Cloud, IaC, 모니터링"
    tone: "실무 중심, 실행 가능한 가이드, 트러블슈팅 관점"
    category: "DevOps"
    default_tags: ["DevOps", "K8s", "Docker", "Kubernetes", "CI/CD"]
    tag_keywords: ["Kubernetes", "Helm", "Terraform", "ArgoCD", "Prometheus", "Grafana"]
    specific_prompt: |
      ### DevOps 분야 작성 특이사항
      1. **실행 가능성**: 모든 코드는 복붙해서 바로 실행 가능해야 함
      2. **단계별 가이드**: Step 1, 2, 3...으로 명확히
      3. **실제 운영 관점**: Production에서의 고려사항
      4. **YAML 중심**: Kubernetes, Helm, Terraform 코드 필수
      5. **모니터링**: Prometheus/Grafana 대시보드 설정 포함
         **Mermaid 다이어그램 심플 디자인 원칙**:
         - 절대 스타일 속성 사용 금지
         - 기본 네모형 노드만 사용

  cve_analyst:
    name: "CVE 취약점 분석가"
    expertise: "CVE 분석, 제로데이 공격, 패치 관리, 익스플로잇"
    tone: "분석적, 신속하고 정확한 정보 전달"
    category: "CVE"
    default_tags: ["CVE", "Vulnerability", "Patch", "Zero-day"]
    tag_keywords: ["CVE", "CVSS", "PoC", "Exploit", "Patch"]
    specific_prompt: |
      ### CVE 분석 작성 특이사항
      1. **정확성 최우선**: 잘못된 정보는 실무 혼란 초래
      2. **신속성**: 최신 CVE는 빠른 분석 필수
      3. **구체성**: "영향 받는 버전" 정확히 명시
      4. **윤리적 경고**: PoC는 방어 목적임 강조
      5. **실행 가능한 완화**: 당장 적용 가능한 조치
         **Mermaid 스타일 필수**:
         - Mermaid 다이어그램 사용 시 **스타일 속성 금지**
         - 기본 네모형 노드만 사용하여 심플하고 깔끔하게 표현

path: scripts/publish_github.py
content: #!/usr/bin/env python3
"""
GitHub Pages 블로그 발행
Jekyll 기반 정적 블로그에 마크다운 포스트 발행
"""

import os
import sys
from pathlib import Path
from datetime import datetime
import subprocess
import re

print("="*70)
print("GITHUB PAGES PUBLISHER")
print("="*70)

class GitHubPagesPublisher:
    def __init__(self):
        self.blog_path = Path(os.getenv('BLOG_LOCAL_PATH', Path.home() / '.openclaw' / 'workspace' / 'blog'))
        self.posts_dir = self.blog_path / 'content' / 'post'
        self.github_token = os.getenv('GITHUB_TOKEN')
        self.github_repo = os.getenv('GITHUB_BLOG_REPO')

    def sanitize_filename(self, title):
        """파일명으로 사용 가능한 문자열로 변환"""
        # 특수문자 제거
        filename = re.sub(r'[^\w\s-]', '', title)
        # 공백을 하이픈으로 변환
        filename = re.sub(r'[\s]+', '-', filename)
        # 최대 길이 제한
        return filename[:100]

    def create_hugo_post(self, article):
        """Hugo 마크다운 포스트 생성"""
        date_str = article['posting_date'].strftime('%Y-%m-%d')
        time_str = article['posting_date'].strftime('%H:%M:%S')
        filename_slug = self.sanitize_filename(article['title'])
        
        # Hugo content 구조: content/post/category/filename/index.md
        category = article.get('category', 'security')
        post_dir = self.posts_dir / category / filename_slug
        post_dir.mkdir(parents=True, exist_ok=True)
        filepath = post_dir / 'index.md'

        # Hugo Front Matter
        front_matter = f"""---
title: "{article['title']}"
date: {date_str}T{time_str}+09:00
draft: false
categories: ["{category}"]
tags: {self._extract_tags(article)}
author: "Intelligence Agent"
---

"""

        # 본문 (마크다운)
        content = article.get('details', article.get('content', ''))

        # 출처 추가
        source = f"\n\n---\n\n**출처**: [{article['category']}]({article['url']})"

        # 파일 작성
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(front_matter + content + source)

        return f"{category}/{filename_slug}/index.md"

    def _extract_tags(self, article):
        """태그 추출 (간단 버전)"""
        # 카테고리 기반 태그
        tags = [article.get('category', 'security')]

        # 제목에서 키워드 추출 (간단 버전)
        title = article['title'].lower()
        keywords = ['cve', 'rce', 'xss', '취약점', '보안', '해킹', '랜섬웨어']
        for keyword in keywords:
            if keyword in title:
                tags.append(keyword)

        return str(tags[:5])  # 최대 5개

    def git_commit_and_push(self, filenames):
        """Git commit & push"""
        try:
            # Git 상태 확인
            subprocess.run(['git', 'status'], cwd=self.blog_path, check=True)

            # 파일 추가
            for filename in filenames:
                filepath = self.posts_dir / filename
                subprocess.run(['git', 'add', str(filepath)], cwd=self.blog_path, check=True)

            # 커밋
            commit_msg = f"Add {len(filenames)} security posts ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
            subprocess.run(['git', 'commit', '-m', commit_msg], cwd=self.blog_path, check=True)

            # 푸시
            subprocess.run(['git', 'push', 'origin', 'main'], cwd=self.blog_path, check=True)

            print(f"✅ Git push 완료: {len(filenames)}개 포스트")
            return True

        except subprocess.CalledProcessError as e:
            print(f"❌ Git 작업 실패: {e}")
            return False

    def publish_batch(self, articles):
        """여러 기사 일괄 발행"""
        print(f"\n🔄 GitHub Pages 발행 시작... ({len(articles)}개)")

        # _posts 디렉토리 확인
        if not self.posts_dir.exists():
            print(f"❌ _posts 디렉토리 없음: {self.posts_dir}")
            print("   블로그 저장소를 먼저 클론하세요:")
            print(f"   git clone https://github.com/{self.github_repo}.git {self.blog_path}")
            return False

        filenames = []
        for i, article in enumerate(articles, 1):
            print(f"  [{i}/{len(articles)}] {article['title'][:40]}...")

            try:
                filename = self.create_jekyll_post(article)
                filenames.append(filename)
                print(f"    ✅ 생성: {filename}")
            except Exception as e:
                print(f"    ❌ 실패: {str(e)[:50]}")

        if filenames:
            return self.git_commit_and_push(filenames)

        return False

def main():
    # 테스트용 샘플 데이터
    sample_article = {
        'title': 'Cisco 제품 보안 업데이트 권고',
        'posting_date': datetime.now(),
        'category': 'KRCERT',
        'url': 'https://knvd.krcert.or.kr/info/vuln/notice/detail?id=xxx',
        'content': 'Cisco 제품의 보안 업데이트 권고...',
        'details': '## 🔍 핵심 요약\n\nCisco 제품의 취약점...\n\n## 📋 상세 분석\n\n...'
    }

    publisher = GitHubPagesPublisher()

    print("\n📝 샘플 포스트 생성 테스트...")
    filename = publisher.create_hugo_post(sample_article)
    print(f"✅ 생성됨: {filename}")

    print("\n📂 블로그 경로:", publisher.blog_path)
    print("📂 Posts 경로:", publisher.posts_dir)

    print("\n" + "="*70)
    print("GitHub Pages Publisher 준비 완료 (Hugo)")
    print("="*70)

if __name__ == '__main__':
    main()


path: scripts/publisher_git.py
content: """
Git Publisher - 사용자 승인 후 Git Push → GitHub Actions 배포
"""

import os
import subprocess
import time
import re
from typing import Dict, List, Optional
from datetime import datetime
from pathlib import Path

from modules.intelligence.notion_publisher import NotionPublisher
from modules.intelligence.config import BLOG_REPO_PATH, BLOG_URL, NOTION_API_KEY, NOTION_DATABASE_ID
from modules.intelligence.utils import setup_logger

logger = setup_logger(__name__, "publisher_git.log")

class GitPublisher:
    """Git 기반 블로그 퍼블리셔"""

    def __init__(
        self,
        blog_repo_path: Path = None,
        blog_url: str = None,
        notion_publisher: NotionPublisher = None
    ):
        self.blog_repo_path = blog_repo_path or BLOG_REPO_PATH
        self.blog_url = blog_url or BLOG_URL
        self.notion_publisher = notion_publisher or NotionPublisher()

        # 유효성 검사
        if not self.blog_repo_path.exists():
            raise ValueError(f"블로그 저장소 경로가 존재하지 않습니다: {self.blog_repo_path}")

        git_dir = self.blog_repo_path / ".git"
        if not git_dir.exists():
            raise ValueError(f"Git 저장소가 아닙니다: {self.blog_repo_path}")

    def monitor_and_publish(self, interval_seconds: int = 300) -> None:
        logger.info(f"Starting monitor (interval: {interval_seconds}s)")
        print("💡 사용자가 Notion에서 상태를 '검토 완료'로 변경하면 자동 배포됩니다.")

        while True:
            try:
                publish_articles = self.notion_publisher.get_review_done_articles()

                if publish_articles:
                    logger.info(f"Found {len(publish_articles)} articles to publish.")
                    for article in publish_articles:
                        self.publish_article(article)
                else:
                    # logger.debug("No articles to publish.")
                    pass

                time.sleep(interval_seconds)

            except KeyboardInterrupt:
                logger.info("Monitoring stopped.")
                break
            except Exception as e:
                logger.error(f"Error during monitoring: {e}")
                time.sleep(interval_seconds)

    def publish_article(self, article: Dict) -> None:
        try:
            page_id = article.get('id', '')
            # Extract title (safe navigation)
            props = article.get('properties', {})
            title_prop = props.get('내용', {}).get('title', [])
            title = title_prop[0].get('text', {}).get('content', 'N/A') if title_prop else 'N/A'

            logger.info(f"Publishing: {title}")

            # 1. Create Post
            slug = self._create_blog_post(article, title)

            # 2. Git Commit
            self._git_commit(title)

            # 3. Git Push
            self._git_push()

            # 4. Update Notion
            blog_url = f"{self.blog_url}p/{slug}/"
            self.notion_publisher.update_published_url(page_id, blog_url)
            self.notion_publisher.update_status(page_id, "게시 완료")

            logger.info(f"Published successfully: {blog_url}")

        except Exception as e:
            logger.error(f"Failed to publish '{title}': {e}")
            try:
                self.notion_publisher.update_status(article.get('id', ''), "검토중")
            except:
                pass

    def _create_blog_post(self, article: Dict, title: str) -> str:
        page_id = article.get('id', '')
        props = article.get('properties', {})
        
        category = props.get('카테고리', {}).get('select', {}).get('name', '보안')
        tags_objs = props.get('테그', {}).get('multi_select', [])
        tags_list = [tag.get('name', '') for tag in tags_objs]

        content = self.notion_publisher.get_page_content(page_id)
        if not content:
            content = "이 글은 LLM을 기반으로 작성되었습니다."

        slug = self._slugify(title)
        post_dir = self.blog_repo_path / "content" / "post" / category / slug
        post_dir.mkdir(parents=True, exist_ok=True)
        
        md_file = post_dir / "index.md"
        
        front_matter = f"""---
title: "{title}"
date: {datetime.now().strftime("%Y-%m-%dT%H:%M:%S+09:00")}
draft: false
tags:
{chr(10).join(f'  - "{tag}"' for tag in tags_list)}
categories:
  - "{category}"
---

{content}
"""
        with open(md_file, 'w', encoding='utf-8') as f:
            f.write(front_matter)
        
        return slug

    def _git_commit(self, title: str) -> None:
        # cwd 변경 대신 subprocess의 cwd 파라미터 사용 권장
        cmd = ['git', 'add', '.']
        subprocess.run(cmd, cwd=self.blog_repo_path, check=True, capture_output=True)
        
        cmd = ['git', 'commit', '-m', f"feat: 블로그 글 추가 - {title}"]
        subprocess.run(cmd, cwd=self.blog_repo_path, check=True, capture_output=True)

    def _git_push(self) -> None:
        cmd = ['git', 'push', 'origin', 'main']
        subprocess.run(cmd, cwd=self.blog_repo_path, check=True, capture_output=True)

    def _slugify(self, text: str) -> str:
        if re.search(r'[가-힣]', text):
            slug = text.replace(' ', '-')
        else:
            slug = text.lower().replace(' ', '-')
        return re.sub(r'[^\w\-]', '', slug)

    def publish_all_pending(self) -> List[Dict]:
        results = []
        publish_articles = self.notion_publisher.get_review_done_articles()
        
        logger.info(f"Publishing all {len(publish_articles)} pending articles...")

        for article in publish_articles:
            try:
                self.publish_article(article)
                results.append({"success": True, "id": article.get('id')})
            except Exception as e:
                results.append({"success": False, "error": str(e)})
        return results

if __name__ == "__main__":
    if not NOTION_API_KEY or not NOTION_DATABASE_ID:
        print("Set NOTION_API_KEY and NOTION_DATABASE_ID in .env")
    else:
        pub = GitPublisher()
        pub.publish_all_pending()

path: scripts/run_git_publisher.py
content: """
Git Publisher Runner - Downloads articles from Notion and publishes to Git
"""

import sys
import os
import subprocess
from pathlib import Path
from datetime import datetime
import importlib.util
from dotenv import load_dotenv

# Load .env
env_path = '/Users/nabang/Documents/OpenClaw/.env'
load_dotenv(env_path, override=True)

# Load NotionPublisher directly
spec = importlib.util.spec_from_file_location(
    "notion_publisher",
    "/Users/nabang/Documents/OpenClaw/modules/intelligence/notion_publisher.py"
)
notion_publisher_module = importlib.util.module_from_spec(spec)

class MockLogger:
    def info(self, msg): print(f"[INFO] {msg}")
    def warning(self, msg): print(f"[WARN] {msg}")
    def error(self, msg): print(f"[ERROR] {msg}")

class MockUtils:
    @staticmethod
    def setup_logger(name, log_file):
        return MockLogger()

sys.modules['modules'] = type(sys)('modules')
sys.modules['modules.intelligence'] = type(sys)('intelligence')
sys.modules['modules.intelligence.config'] = type('Config', (), {
    'NOTION_API_KEY': os.getenv('NOTION_API_KEY', ''),
    'NOTION_DATABASE_ID': os.getenv('NOTION_DATABASE_ID') or os.getenv('BLOG_DATABASE_ID', ''),
    'BLOG_REPO_PATH': Path(os.getenv('BLOG_REPO_PATH', '~/Documents/OpenClaw/rebugui.github.io')),
    'BLOG_URL': os.getenv('BLOG_URL', 'https://rebugui.github.io/'),
})
sys.modules['modules.intelligence.utils'] = MockUtils

import requests
spec.loader.exec_module(notion_publisher_module)
NotionPublisher = notion_publisher_module.NotionPublisher

def publish_article(article, publisher, blog_repo_path, blog_url):
    """Publish a single article from Notion to Git"""
    try:
        page_id = article.get('id', '')
        props = article.get('properties', {})
        title_prop = props.get('내용', {}).get('title', [])
        title = title_prop[0].get('text', {}).get('content', 'N/A') if title_prop else 'N/A'

        print(f"\n{'='*60}")
        print(f"Publishing: {title}")
        print(f"{'='*60}")

        # Get content
        content = publisher.get_page_content(page_id)
        if not content:
            content = "이 글은 LLM을 기반으로 작성되었습니다."

        # Check for Mermaid
        mermaid_count = content.count('```mermaid')
        if mermaid_count > 0:
            print(f"✓ Found {mermaid_count} Mermaid diagram(s)")

        # Get metadata
        category = props.get('카테고리', {}).get('select', {}).get('name', '보안')
        tags_objs = props.get('테그', {}).get('multi_select', [])
        tags_list = [tag.get('name', '') for tag in tags_objs]

        # Create slug
        import re
        if re.search(r'[가-힣]', title):
            slug = title.replace(' ', '-')
        else:
            slug = title.lower().replace(' ', '-')
        slug = re.sub(r'[^\w\-]', '', slug)

        # Create directory
        post_dir = blog_repo_path / "content" / "post" / category / slug
        post_dir.mkdir(parents=True, exist_ok=True)

        # Create markdown file
        md_file = post_dir / "index.md"
        front_matter = f"""---
title: "{title}"
date: {datetime.now().strftime("%Y-%m-%dT%H:%M:%S+09:00")}
draft: false
tags:
{chr(10).join(f'  - "{tag}"' for tag in tags_list)}
categories:
  - "{category}"
---

{content}
"""

        with open(md_file, 'w', encoding='utf-8') as f:
            f.write(front_matter)

        print(f"✓ Created: {md_file}")
        print(f"  Category: {category}")
        print(f"  Tags: {', '.join(tags_list)}")
        print(f"  Content length: {len(content)} chars")

        # Git operations
        print("\n[Git] Adding changes...")
        subprocess.run(['git', 'add', '.'],
                      cwd=blog_repo_path,
                      check=True,
                      capture_output=True)

        print("[Git] Committing...")
        subprocess.run(['git', 'commit', '-m', f"feat: 블로그 글 추가 - {title}"],
                      cwd=blog_repo_path,
                      check=True,
                      capture_output=True)

        print("[Git] Pushing to remote...")
        subprocess.run(['git', 'push', 'origin', 'main'],
                      cwd=blog_repo_path,
                      check=True,
                      capture_output=True)

        print("✓ Git push completed")

        # Update Notion
        blog_url_full = f"{blog_url}p/{slug}/"
        publisher.update_published_url(page_id, blog_url_full)
        publisher.update_status(page_id, "게시 완료")

        print(f"✓ Published: {blog_url_full}")

        return True

    except Exception as e:
        print(f"✗ Failed: {e}")
        import traceback
        traceback.print_exc()
        return False

def main():
    print("=" * 70)
    print("Git Publisher - Notion → Git → GitHub")
    print("=" * 70)

    publisher = NotionPublisher()
    blog_repo_path = Path(os.getenv('BLOG_REPO_PATH', '~/Documents/OpenClaw/rebugui.github.io')).expanduser()
    blog_url = os.getenv('BLOG_URL', 'https://rebugui.github.io/')

    print(f"\nBlog Repository: {blog_repo_path}")
    print(f"Blog URL: {blog_url}")

    # Get review done articles
    print("\n[Step 1] Fetching '검토 완료' articles from Notion...")
    articles = publisher.get_review_done_articles()

    if not articles:
        print("⚠️  No articles with '검토 완료' status found.")
        return

    print(f"✓ Found {len(articles)} article(s)")

    # Publish each article
    results = []
    for idx, article in enumerate(articles, 1):
        print(f"\n[Step 2.{idx}] Processing article {idx}/{len(articles)}...")
        success = publish_article(article, publisher, blog_repo_path, blog_url)
        results.append(success)

    # Summary
    print("\n" + "=" * 70)
    print("Publishing Summary")
    print("=" * 70)
    print(f"Total: {len(articles)} articles")
    print(f"Success: {sum(results)} articles")
    print(f"Failed: {len(results) - sum(results)} articles")

    if all(results):
        print("\n✅ All articles published successfully!")

        # Next steps
        print("\nNext steps:")
        print(f"1. Check the blog repository: {blog_repo_path}")
        print("2. Build Hugo site:")
        print(f"   cd {blog_repo_path} && hugo")
        print("3. Check the deployed blog")
    else:
        print("\n⚠️  Some articles failed to publish. Check the errors above.")

if __name__ == "__main__":
    main()


path: scripts/run_pipeline.py
content: #!/usr/bin/env python3
"""
Intelligence Agent - 전체 파이프라인 실행
Security News Module + GLM-5 + Notion + Tistory + Notifications
"""

import sys
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
import argparse
from datetime import datetime, timedelta

# 환경 변수 로드
from dotenv import load_dotenv
env_file = Path.home() / '.openclaw' / 'workspace' / '.env'
load_dotenv(env_file)

# Security News Module 경로 추가 (security-news-feed 사용)
skill_path = Path(__file__).parent.parent.parent / 'security-news-feed'
sys.path.insert(0, str(skill_path))

import os
os.chdir(str(skill_path))  # working directory 변경

print("="*70)
print("INTELLIGENCE AGENT - FULL PIPELINE")
print("="*70)

def collect_and_publish():
    """Security News Module로 수집 및 발행"""
    from modules.crawlers.krcert import KRCERTCrawler
    from modules.crawlers.dailysecu import DailySecuCrawler
    from modules.crawlers.boannews import BoanNewsCrawler
    from modules.crawlers.ahnlab import AhnLabCrawler
    from modules.crawlers.boho import BohoCrawler
    from modules.crawlers.igloo import IglooCrawler
    from modules.crawlers.kisa import KISACrawler
    from modules.crawlers.ncsc import NCSCCrawler
    from modules.crawlers.skshieldus import SKShieldusCrawler
    from modules.publisher_service import PublisherService
    from modules.llm_handler import summarize_text, details_text
    from config import BOANISSUE_DATABASE_ID
    import time

    print("\n🔄 Step 1: 기사 수집 (9개 크롤러)")

    crawlers = [
        ("KRCERT", KRCERTCrawler),
        ("데일리시큐", DailySecuCrawler),
        ("보안뉴스", BoanNewsCrawler),
        ("AhnLab", AhnLabCrawler),
        ("Boho", BohoCrawler),
        ("Igloo", IglooCrawler),
        ("KISA", KISACrawler),
        ("NCSC", NCSCCrawler),
        ("SKShieldus", SKShieldusCrawler),
    ]

    publisher = PublisherService(
        enable_notion=True,
        enable_tistory=False,
        notion_database_id=BOANISSUE_DATABASE_ID
    )

    total_collected = 0
    total_published = 0

    for name, crawler_class in crawlers:
        print(f"\n{name}...")

        try:
            crawler = crawler_class()
            result = crawler.run(publisher_service=None)

            collected = len(result.get('queue', []))
            print(f"  ✅ 수집: {collected}개")

            if collected > 0:
                total_collected += collected

                # 발행
                for i, article in enumerate(result['queue'], 1):
                    print(f"  [{i}/{collected}] {article['title'][:40]}...")

                    try:
                        # GLM-5 요약
                        summary = summarize_text(article['content'], article['title'])
                        time.sleep(1)  # Rate limit 방지

                        # GLM-5 상세 분석
                        details = details_text(article['content'], article['title'])
                        time.sleep(1)

                        # Notion 발행
                        pub_result = publisher.publish_article(
                            title=article['title'],
                            content=summary,
                            url=article['url'],
                            date=article['posting_date'].strftime('%Y-%m-%d'),
                            category=article['category'],
                            details=details,
                            database_id=BOANISSUE_DATABASE_ID
                        )

                        if pub_result.get('notion'):
                            total_published += 1
                            print("    ✅ 발행 성공")
                        else:
                            print("    ❌ 발행 실패")

                        time.sleep(2)  # Rate limit 방지

                    except Exception as e:
                        print(f"    ❌ Error: {str(e)[:50]}")
                        continue

        except Exception as e:
            print(f"  ❌ Error: {str(e)[:50]}")

    print("\n" + "="*70)
    print("수집 및 발행 완료")
    print("="*70)
    print(f"📰 수집: {total_collected}개")
    print(f"✅ 발행: {total_published}개")
    print(f"📊 성공률: {total_published/total_collected*100:.1f}%" if total_collected > 0 else "N/A")

    return total_collected, total_published

def send_notifications(collected, published):
    """알림 발송"""
    print("\n🔄 Step 2: 알림 발송")

    # Slack 알림 (구현 필요)
    print("  ⏳ Slack 알림 - 구현 예정")

    # Email 알림 (구현 필요)
    print("  ⏳ Email 알림 - 구현 예정")

    print("  ✅ 알림 발송 완료 (준비 중)")

def generate_weekly_report():
    """주간 리포트 생성"""
    print("\n🔄 Step 3: 주간 리포트 생성")

    # Notion에서 지난 주 데이터 가져오기 (구현 필요)
    print("  ⏳ 데이터 분석 - 구현 예정")

    # 리포트 생성 (구현 필요)
    print("  ⏳ 리포트 작성 - 구현 예정")

    # 발송 (구현 필요)
    print("  ⏳ 발송 - 구현 예정")

    print("  ✅ 주간 리포트 완료 (준비 중)")

def publish_to_github_pages(articles):
    """GitHub Pages 발행"""
    print("\n🔄 Step 4: GitHub Pages 발행")

    if not articles:
        print("  ❌ 발행할 기사가 없습니다.")
        return False

    try:
        # GitHub Pages Publisher 임포트
        sys.path.insert(0, str(Path(__file__).parent))
        from publish_github import GitHubPagesPublisher

        publisher = GitHubPagesPublisher()
        result = publisher.publish_batch(articles)

        if result:
            print(f"  ✅ GitHub Pages 발행 완료: {len(articles)}개")
        else:
            print("  ❌ GitHub Pages 발행 실패")

        return result

    except Exception as e:
        print(f"  ❌ Error: {str(e)[:50]}")
        import traceback
        traceback.print_exc()
        return False

def main():
    parser = argparse.ArgumentParser(description='Intelligence Agent Pipeline')
    parser.add_argument('--full', action='store_true', help='전체 파이프라인 (수집 + 발행 + 알림)')
    parser.add_argument('--publish', action='store_true', help='발행만')
    parser.add_argument('--notify', action='store_true', help='알림만')
    parser.add_argument('--weekly', action='store_true', help='주간 리포트만')
    parser.add_argument('--tistory', action='store_true', help='Tistory 발행만')

    args = parser.parse_args()

    if not any([args.full, args.publish, args.notify, args.weekly, args.tistory]):
        parser.print_help()
        return

    start_time = datetime.now()
    print(f"\n시작: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")

    collected, published = 0, 0

    if args.full or args.publish:
        collected, published = collect_and_publish()

    if args.full or args.notify:
        send_notifications(collected, published)

    if args.weekly:
        generate_weekly_report()

    if args.tistory:
        publish_to_tistory()

    end_time = datetime.now()
    duration = end_time - start_time

    print("\n" + "="*70)
    print("파이프라인 완료")
    print("="*70)
    print(f"시작: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"종료: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"소요 시간: {duration}")

if __name__ == '__main__':
    main()


path: scripts/selector.py
content: """
Article Selector - AI 기반 기사 평가 및 선별 모듈 (Async + Pydantic)
"""

import json
import re
import asyncio
from typing import List, Dict
from collections import defaultdict
from modules.intelligence.llm_client_async import AsyncLLMClient
from modules.intelligence.writer import CategoryClassifier, Persona, PersonaConfig
from modules.intelligence.utils import setup_logger
from modules.intelligence.prompt_manager import PromptManager
from modules.intelligence.models import EvaluationResponse

logger = setup_logger(__name__, "selector.log")

class ArticleSelector:
    """AI를 사용하여 수집된 기사 중 최적의 기사를 선별"""

    def __init__(self, client: AsyncLLMClient = None):
        self.client = client or AsyncLLMClient()

    async def evaluate_and_select(self, articles: List[Dict], max_articles: int = 5, min_score: int = 6) -> List[Dict]:
        if not articles:
            return []

        logger.info(f"Evaluating {len(articles)} articles for selection (Async)...")

        # 1. 카테고리별 그룹화
        category_groups = defaultdict(list)
        for i, article in enumerate(articles):
            persona = CategoryClassifier.classify(article)
            config = PersonaConfig.get(persona)
            category = config.get("category", "IT")
            
            article['_temp_id'] = i
            article['_category'] = category
            category_groups[category].append(article)

        # 2. 각 카테고리별 평가 태스크 생성
        tasks = []
        for category, items in category_groups.items():
            if not items:
                continue
            tasks.append(self._score_articles_in_category(category, items))
        
        # 3. 병렬 실행 및 결과 수집
        scored_results = await asyncio.gather(*tasks)
        
        # 4. 점수 매핑
        for result_list in scored_results:
            scored_dict = {item.id: item for item in result_list}
            for item in result_list:
                original_idx = item.id
                if 0 <= original_idx < len(articles):
                    articles[original_idx]['_score'] = item.score
                    articles[original_idx]['_reason'] = item.reason

        # 5. 카테고리별 최종 선택 (Round-robin)
        final_selected = []
        categories = list(category_groups.keys())
        for cat in categories:
            category_groups[cat].sort(key=lambda x: x.get('_score', 0), reverse=True)

        while len(final_selected) < max_articles:
            all_empty = True
            for cat in categories:
                if len(final_selected) >= max_articles:
                    break
                
                if category_groups[cat]:
                    all_empty = False
                    article = category_groups[cat].pop(0)
                    
                    score = article.get('_score', 0)
                    if score < min_score:
                        logger.info(f"Skipped [{cat}] '{article['title'][:30]}...' (Score: {score} < {min_score})")
                        continue

                    final_selected.append(article)
                    logger.info(f"Selected [{cat}] (Score: {score}): {article['title'][:50]}...")
            
            if all_empty:
                break

        return final_selected

    async def _score_articles_in_category(self, category: str, items: List[Dict]):
        """AI 평가 (Pydantic 검증)"""
        if len(items) == 1:
             # Mock object for single item
             from modules.intelligence.models import EvaluationItem
             return [EvaluationItem(id=items[0]['_temp_id'], score=9, reason="Only article in category")]

        articles_text = ""
        for item in items:
            safe_summary = (item.get('summary') or "")[:200].replace('\n', ' ')
            articles_text += f"ID: {item['_temp_id']}\nTitle: {item['title']}\nSummary: {safe_summary}\n---\n"

        system_prompt = PromptManager.get("selector.system", category=category)
        user_prompt = PromptManager.get("selector.user", category=category, articles_text=articles_text)

        try:
            response = await self.client.chat(system_prompt, user_prompt)
            
            # JSON 추출
            json_match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL)
            json_str = json_match.group(1) if json_match else response.strip()
            if '{' in json_str:
                 json_str = json_str[json_str.find('{'):json_str.rfind('}')+1]

            # Pydantic 파싱
            data = EvaluationResponse.model_validate_json(json_str)
            return data.evaluations

        except Exception as e:
            logger.error(f"Failed to score articles in {category}: {e}")
            return []

path: scripts/test_full_pipeline.py
content: """
Full Pipeline Test - Create test article → Upload to Notion → Download → Verify
"""

import sys
import os
import importlib.util
from pathlib import Path
from dotenv import load_dotenv

# Load .env
env_path = '/Users/nabang/Documents/OpenClaw/.env'
load_dotenv(env_path, override=True)

# Load NotionPublisher directly
spec = importlib.util.spec_from_file_location(
    "notion_publisher",
    "/Users/nabang/Documents/OpenClaw/modules/intelligence/notion_publisher.py"
)
notion_publisher_module = importlib.util.module_from_spec(spec)

class MockLogger:
    def info(self, msg): pass
    def warning(self, msg): pass
    def error(self, msg): pass

class MockUtils:
    @staticmethod
    def setup_logger(name, log_file):
        return MockLogger()

sys.modules['modules'] = type(sys)('modules')
sys.modules['modules.intelligence'] = type(sys)('intelligence')
sys.modules['modules.intelligence.config'] = type('Config', (), {
    'NOTION_API_KEY': os.getenv('NOTION_API_KEY', ''),
    'NOTION_DATABASE_ID': os.getenv('NOTION_DATABASE_ID') or os.getenv('BLOG_DATABASE_ID', ''),
    'BLOG_REPO_PATH': Path(os.getenv('BLOG_REPO_PATH', '~/Documents/OpenClaw/rebugui.github.io')).expanduser(),
    'BLOG_URL': os.getenv('BLOG_URL', 'https://rebugui.github.io/hate-coding-turtle/'),
})
sys.modules['modules.intelligence.utils'] = MockUtils

import requests
spec.loader.exec_module(notion_publisher_module)
NotionPublisher = notion_publisher_module.NotionPublisher

def create_test_article_with_mermaid():
    """Create a test article with Mermaid diagram"""
    return {
        'title': '🧪 파이프라인 테스트: Mermaid 다이어그램',
        'summary': '이 글은 인텔리전스 에이전트 파이프라인 테스트를 위해 자동으로 생성되었습니다.',
        'category': '보안',
        'tags': ['Test', 'Mermaid', 'Pipeline', '자동화'],
        'content': '''
## 서론

이 글은 인텔리전스 에이전트 파이프라인의 Mermaid 다이어그램 변환 기능을 테스트하기 위해 생성되었습니다.

## 파이프라인 아키텍처

아래 다이어그램은 전체 파이프라인의 흐름을 보여줍니다.

```mermaid
graph TD
    A[AI Writer] -->|Generate Markdown| B[Notion Publisher]
    B -->|Convert to Callout| C[Notion Database]
    C -->|Export| D[Git Publisher]
    D -->|Convert to Mermaid| E[Git Repository]
    E -->|Build| F[Hugo Blog]
    F -->|Render| G[Published Blog]

    style A fill:#e1f5ff
    style B fill:#fff4e1
    style C fill:#f0e1ff
    style D fill:#fff4e1
    style E fill:#e1ffe1
    style F fill:#ffe1f0
    style G fill:#e1ffe1
```

## 테스트 코드 예시

파이프라인 테스트를 위한 Python 코드입니다.

~~~python
def test_mermaid_conversion():
    test_markdown = '~~~mermaid\\ngraph LR\\n    A[Start] --> B[End]\\n~~~'

    publisher = NotionPublisher()
    blocks = publisher._convert_to_blocks(test_markdown)
    restored = publisher._block_to_text(blocks[0])

    assert '```mermaid' in restored
    print("✓ Mermaid conversion test passed!")
~~~

## 테스트 결과

| 항목 | 예상 | 실제 | 상태 |
|:---|:---|:---|:---|
| Mermaid 변환 | Callout → Mermaid | Callout → Mermaid | ✅ |
| 코드 블록 | 정상 유지 | 정상 유지 | ✅ |
| 테이블 | 정상 렌더링 | 정상 렌더링 | ✅ |

## 결론

이 테스트는 다음을 검증합니다:

1. ✅ AI Writer가 생성한 ```mermaid 블록이 Notion에 업로드될 때 Callout(📊)으로 변환됨
2. ✅ Git Publisher가 Notion에서 내려받을 때 Callout(📊)을 ```mermaid로 복원함
3. ✅ Hugo가 ```mermaid를 정상적으로 렌더링함

파이프라인이 정상 작동합니다!
'''
    }

def test_full_pipeline():
    """Test complete pipeline: Create → Upload → Download → Verify"""
    print("=" * 70)
    print("Full Pipeline Test: Create → Upload → Download → Verify")
    print("=" * 70)

    try:
        publisher = NotionPublisher()

        # Step 1: Create test article
        print("\n[Step 1] Creating test article with Mermaid diagram...")
        article_data = create_test_article_with_mermaid()
        print(f"  Title: {article_data['title']}")
        print(f"  Tags: {', '.join(article_data['tags'])}")
        print(f"  Content length: {len(article_data['content'])} characters")

        # Count Mermaid blocks in original
        original_mermaid_count = article_data['content'].count('```mermaid')
        print(f"  Mermaid blocks: {original_mermaid_count}")

        # Step 2: Upload to Notion
        print("\n[Step 2] Uploading to Notion...")
        result = publisher.create_article(article_data)

        if not result:
            print("  ✗ Failed to upload to Notion")
            return False

        page_id = result.get('id')
        page_url = result.get('url')
        print(f"  ✓ Uploaded successfully!")
        print(f"  Page ID: {page_id}")
        print(f"  URL: {page_url}")

        # Step 3: Download from Notion
        print("\n[Step 3] Downloading from Notion...")
        downloaded_content = publisher.get_page_content(page_id)
        print(f"  Downloaded content length: {len(downloaded_content)} characters")

        # Step 4: Verify Mermaid conversion
        print("\n[Step 4] Verifying Mermaid conversion...")

        downloaded_mermaid_count = downloaded_content.count('```mermaid')
        print(f"  Original Mermaid blocks: {original_mermaid_count}")
        print(f"  Downloaded Mermaid blocks: {downloaded_mermaid_count}")

        if downloaded_mermaid_count == original_mermaid_count:
            print("  ✓ Mermaid blocks preserved!")
        else:
            print(f"  ✗ Mermaid block count mismatch!")
            return False

        # Extract and display a Mermaid block
        if '```mermaid' in downloaded_content:
            start_idx = downloaded_content.find('```mermaid')
            end_idx = downloaded_content.find('```', start_idx + 10)
            if end_idx > start_idx:
                mermaid_sample = downloaded_content[start_idx:end_idx + 3]
                print("\n  Sample Mermaid block from Notion:")
                print("  " + "-" * 50)
                lines = mermaid_sample.split('\n')[:15]
                for line in lines:
                    print(f"  {line}")
                if len(mermaid_sample.split('\n')) > 15:
                    print("  ...")
                print("  " + "-" * 50)

        # Update status to 검토 완료 for Git Publisher test
        print("\n[Step 5] Updating status to '검토 완료'...")
        publisher.update_status(page_id, "검토 완료")
        print("  ✓ Status updated!")

        print("\n" + "=" * 70)
        print("✅ Full Pipeline Test PASSED!")
        print("=" * 70)
        print("\nNext steps:")
        print("1. Check the article in Notion:")
        print(f"   {page_url}")
        print("2. Run Git Publisher to download to Git repository:")
        print("   python3 publisher_git.py")
        print("3. Verify the generated markdown file has Mermaid diagrams")
        print("4. Build Hugo site to verify rendering:")
        print("   cd ~/Documents/OpenClaw/rebugui.github.io && hugo")

        return True

    except Exception as e:
        print(f"\n✗ Test failed: {e}")
        import traceback
        traceback.print_exc()
        return False

if __name__ == "__main__":
    print("\n🚀 Starting Full Pipeline Test...\n")
    success = test_full_pipeline()

    if success:
        print("\n✅ Test completed successfully!")
    else:
        print("\n❌ Test failed. Please check the errors above.")


path: scripts/test_mermaid_fix.py
content: """
Test script for Mermaid diagram conversion in Notion Publisher
Tests the bidirectional conversion of Mermaid diagrams
"""

import sys
import os

# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# Direct import to avoid package initialization issues
import importlib.util
spec = importlib.util.spec_from_file_location("notion_publisher", "/Users/nabang/Documents/OpenClaw/modules/intelligence/notion_publisher.py")
notion_publisher_module = importlib.util.module_from_spec(spec)

# Mock the config module to avoid import errors
class MockConfig:
    NOTION_API_KEY = "test_key"
    NOTION_DATABASE_ID = "test_db_id"

sys.modules['modules.intelligence.config'] = MockConfig()
sys.modules['modules'] = type(sys)('modules')
sys.modules['modules'].intelligence = type(sys)('intelligence')
sys.modules['modules'].intelligence.config = MockConfig()

# Mock logger
class MockLogger:
    def info(self, msg): pass
    def warning(self, msg): pass
    def error(self, msg): pass

class MockUtils:
    @staticmethod
    def setup_logger(name, log_file):
        return MockLogger()

sys.modules['modules.intelligence.utils'] = MockUtils()

# Now load the module
spec.loader.exec_module(notion_publisher_module)
NotionPublisher = notion_publisher_module.NotionPublisher

def test_mermaid_to_notion_blocks():
    """Test converting Mermaid markdown to Notion blocks"""
    print("=== Test 1: Markdown → Notion Blocks ===")

    publisher = NotionPublisher()

    # Sample markdown with Mermaid diagram
    markdown_content = """
## 서론

이것은 테스트입니다.

```mermaid
graph TD
    A[Start] --> B[End]
```

결론입니다.
"""

    blocks = publisher._convert_to_blocks(markdown_content)

    print(f"Total blocks created: {len(blocks)}")
    for i, block in enumerate(blocks):
        print(f"\nBlock {i+1}:")
        print(f"  Type: {block.get('type', 'unknown')}")
        if block.get('type') == 'callout':
            emoji = block.get('callout', {}).get('icon', {}).get('emoji', '')
            text = block.get('callout', {}).get('rich_text', [{}])[0].get('text', {}).get('content', '')
            print(f"  Emoji: {emoji}")
            print(f"  Text preview: {text[:50]}...")

            # Verify it's a Mermaid callout
            if emoji == '📊':
                print("  ✓ Mermaid correctly converted to Callout with 📊 emoji")
            else:
                print(f"  ✗ Expected 📊 emoji, got {emoji}")
        elif block.get('type') == 'heading_2':
            text = block.get('heading_2', {}).get('rich_text', [{}])[0].get('text', {}).get('content', '')
            print(f"  Text: {text}")

    return blocks

def test_notion_blocks_to_markdown():
    """Test converting Notion blocks back to Markdown"""
    print("\n=== Test 2: Notion Blocks → Markdown ===")

    publisher = NotionPublisher()

    # Simulate a Notion Callout block with Mermaid diagram
    mermaid_code = """graph TD
    A[Start] --> B[End]
    B --> C[Finish]"""

    notion_block = {
        "object": "block",
        "type": "callout",
        "callout": {
            "rich_text": [{"type": "text", "text": {"content": mermaid_code}}],
            "icon": {"emoji": "📊"}
        }
    }

    markdown = publisher._block_to_text(notion_block)
    print(f"Converted Markdown:\n{markdown}")

    # Verify it's a Mermaid code block
    if markdown.startswith("```mermaid") and markdown.endswith("```"):
        print("✓ Callout with 📊 emoji correctly converted back to ```mermaid code block")
        return True
    else:
        print(f"✗ Expected ```mermaid code block, got: {markdown[:50]}...")
        return False

def test_regular_callout():
    """Test that regular Callouts are not converted to Mermaid"""
    print("\n=== Test 3: Regular Callout (not Mermaid) ===")

    publisher = NotionPublisher()

    # Regular callout with different emoji
    notion_block = {
        "object": "block",
        "type": "callout",
        "callout": {
            "rich_text": [{"type": "text", "text": {"content": "This is a regular callout"}}],
            "icon": {"emoji": "💡"}
        }
    }

    markdown = publisher._block_to_text(notion_block)
    print(f"Converted Markdown: {markdown}")

    # Verify it's NOT a Mermaid code block
    if not markdown.startswith("```mermaid"):
        print("✓ Regular callout preserved as regular callout")
        return True
    else:
        print("✗ Regular callout incorrectly converted to Mermaid")
        return False

def test_roundtrip_conversion():
    """Test full roundtrip: Markdown → Notion → Markdown"""
    print("\n=== Test 4: Roundtrip Conversion ===")

    publisher = NotionPublisher()

    original_markdown = """```mermaid
graph LR
    A[Attacker] --> B[Victim]
    B --> C[Compromise]
```"""

    # Step 1: Markdown → Notion blocks
    blocks = publisher._convert_to_blocks(original_markdown)
    print(f"Step 1 - Converted to {len(blocks)} blocks")

    # Step 2: Notion blocks → Markdown
    if blocks:
        restored_markdown = publisher._block_to_text(blocks[0])
        print(f"Step 2 - Restored markdown:\n{restored_markdown}")

        # Verify roundtrip preservation
        if restored_markdown.strip() == original_markdown.strip():
            print("✓ Roundtrip successful: Mermaid code preserved")
            return True
        else:
            print("⚠ Roundtrip warning: Content may differ slightly")
            print(f"Original: {original_markdown[:50]}...")
            print(f"Restored: {restored_markdown[:50]}...")
            # Check if key parts are preserved
            if "```mermaid" in restored_markdown and "graph LR" in restored_markdown:
                print("✓ At least Mermaid format and content preserved")
                return True
            else:
                print("✗ Roundtrip failed: Mermaid format lost")
                return False
    else:
        print("✗ No blocks generated")
        return False

if __name__ == "__main__":
    print("Testing Mermaid Diagram Conversion Fix\n")
    print("=" * 60)

    try:
        # Run all tests
        test_mermaid_to_notion_blocks()
        test_notion_blocks_to_markdown()
        test_regular_callout()
        test_roundtrip_conversion()

        print("\n" + "=" * 60)
        print("All tests completed!")

    except Exception as e:
        print(f"\n✗ Test failed with error: {e}")
        import traceback
        traceback.print_exc()


path: scripts/topic_analyzer.py
content: """
Topic Analyzer - 기사 그룹화 및 주제 분석
"""

import json
import re
from typing import List, Dict, Optional
from datetime import datetime
from llm_client import GLMClient
from modules.intelligence.writer import BlogWriter, Persona, CategoryClassifier, TagExtractor, PersonaConfig
from modules.intelligence.config import GLM_API_KEY
from modules.intelligence.utils import setup_logger

logger = setup_logger(__name__, "topic_analyzer.log")

class TopicAnalyzer:
    """주제 분석기"""

    def __init__(self, [REDACTED_SECRET] = None):
        self.client = GLMClient([REDACTED_SECRET] or GLM_API_KEY)
        self.writer = BlogWriter([REDACTED_SECRET])

    def analyze_and_group_articles(self, articles: List[Dict], max_groups: int = 5) -> List[Dict]:
        if not articles:
            return []

        logger.info(f"Analyzing {len(articles)} articles...")

        articles_summary = [
            f"[{i}] {a.get('title')} (Source: {a.get('source')})\n    Summary: {a.get('summary')[:200]}"
            for i, a in enumerate(articles)
        ]
        articles_text = "\n".join(articles_summary)

        system_prompt = """You are a news analysis expert.
Analyze the provided articles and group them into 3-5 interesting topics.
Output MUST be in JSON format:
{
  "topics": [
    {
      "topic_title": "Topic Title",
      "description": "Why this is important",
      "related_articles": [0, 2, 5],
      "angle": "Key perspective"
    }
  ]
}
"""
        user_prompt = f"Analyze these articles:\n{articles_text}"

        try:
            response = self.client.chat(system_prompt, user_prompt)
            
            # JSON parsing
            json_match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL)
            json_str = json_match.group(1) if json_match else response.strip()
            # If plain text without code blocks, try to find { ... }
            if not json_match and '{' in response:
                 json_str = response[response.find('{'):response.rfind('}')+1]

            data = json.loads(json_str)
            topics = data.get("topics", [])

            grouped = []
            for topic in topics:
                indices = topic.get("related_articles", [])
                topic_articles = [articles[i] for i in indices if 0 <= i < len(articles)]
                
                if topic_articles:
                    grouped.append({
                        "topic": topic.get("topic_title"),
                        "description": topic.get("description"),
                        "angle": topic.get("angle"),
                        "articles": topic_articles,
                        "count": len(topic_articles)
                    })
            
            logger.info(f"Identified {len(grouped)} topics.")
            return grouped

        except Exception as e:
            logger.error(f"Topic analysis failed: {e}")
            return [{
                "topic": "General Updates",
                "description": "Mixed articles",
                "angle": "General Overview",
                "articles": articles,
                "count": len(articles)
            }]

    def generate_comprehensive_article(self, group: Dict) -> Dict:
        """종합 기사 생성"""
        topic = group["topic"]
        articles = group["articles"]
        angle = group.get("angle", "")
        
        logger.info(f"Generating comprehensive article for '{topic}'")

        # Persona & Config
        first_article = articles[0]
        persona = CategoryClassifier.classify(first_article)
        config = PersonaConfig.get(persona)
        
        category = TagExtractor.get_category(persona)
        all_tags = set()
        for a in articles:
            all_tags.update(TagExtractor.extract_tags(a, persona, max_tags=3))
        tags_list = list(all_tags)[:5]

        # Prompt
        system_prompt = f"""You are {config['name']}.
Expertise: {config['expertise']}
Tone: {config['tone']}

Write a comprehensive blog post about "{topic}".
Perspective: {angle}
Format: Markdown
Include: Title, Summary, Introduction, Main Body (synthesis of articles), Expert Insight, Conclusion, References.
"""
        articles_text = "\n".join([f"- {a.get('title')} ({a.get('url')})" for a in articles])
        user_prompt = f"Topic: {topic}\nPerspective: {angle}\nArticles:\n{articles_text}\n\nWrite the post."

        try:
            content = self.client.chat(system_prompt, user_prompt)
            
            # Simple parsing
            result = {
                "title": f"[종합] {topic}",
                "summary": f"{topic} 관련 종합 분석",
                "content": content,
                "tags": tags_list,
                "category": category,
                "persona": persona.value,
                "original_url": articles[0].get('url', ''),
                "original_source": "AI Topic Analysis",
                "source_articles": len(articles),
                "ai_derived_topic": topic
            }
            
            # Extract title if possible
            for line in content.split('\n'):
                if line.startswith("제목:") or line.startswith("# "):
                    result["title"] = line.replace("제목:", "").replace("# ", "").strip()
                    break

            return result
        except Exception as e:
            logger.error(f"Failed to generate comprehensive article: {e}")
            raise

    def analyze_and_generate(self, articles: List[Dict], max_groups: int = 5) -> List[Dict]:
        groups = self.analyze_and_group_articles(articles, max_groups)
        results = []
        for group in groups:
            try:
                res = self.generate_comprehensive_article(group)
                results.append(res)
            except Exception:
                pass
        return results

if __name__ == "__main__":
    analyzer = TopicAnalyzer()
    print("TopicAnalyzer initialized.")

path: scripts/utils.py
content: """
Utilities Module
로깅, 공통 헬퍼 함수 등을 제공합니다.
"""

import logging
import sys
from logging.handlers import RotatingFileHandler
from modules.intelligence.config import LOG_DIR

def setup_logger(name: str, log_file: str = None, level=logging.INFO) -> logging.Logger:
    """
    로거 설정 및 반환

    Args:
        name: 로거 이름 (__name__)
        log_file: 로그 파일명 (예: 'pipeline.log'). None이면 파일 로깅 안함.
        level: 로그 레벨

    Returns:
        설정된 Logger 인스턴스
    """
    logger = logging.getLogger(name)
    logger.setLevel(level)

    # 핸들러가 이미 있다면 추가하지 않음 (중복 로그 방지)
    if logger.handlers:
        return logger

    # 포맷터
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )

    # 1. 콘솔 핸들러
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)

    # 2. 파일 핸들러 (옵션)
    if log_file:
        log_path = LOG_DIR / log_file
        # RotatingFileHandler: 10MB 단위로 최대 5개 파일 유지
        file_handler = RotatingFileHandler(
            log_path, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8'
        )
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)

    return logger


path: scripts/writer.py
content: """
LLM Writer - 다중 페르소나 블로그 글 작성 시스템 (Async + Pydantic)
"""

import os
import re
import json
import asyncio
from typing import List, Dict, Optional
from datetime import datetime
from enum import Enum
from modules.intelligence.config import GLM_API_KEY, GLM_BASE_URL, GLM_MODEL
from modules.intelligence.llm_client_async import AsyncLLMClient
from modules.intelligence.utils import setup_logger
from modules.intelligence.prompt_manager import PromptManager
from modules.intelligence.models import BlogPost

logger = setup_logger(__name__, "writer.log")

class Persona(Enum):
    SECURITY = "security"
    AI_ML = "ai_ml"
    DEVOPS = "devops"
    CVE_ANALYST = "cve_analyst"

class PersonaConfig:
    NOTION_CATEGORIES = ["보안", "AI", "DevOps", "CVE", "IT"]
    
    # 기존 하드코딩된 PERSONAS 딕셔너리는 제거되고 prompts.yaml로 이동됨

    @classmethod
    def get(cls, persona: Persona) -> Dict:
        """PromptManager를 통해 페르소나 설정을 로드합니다."""
        persona_key = persona.value
        config = PromptManager.get_raw(f"personas.{persona_key}")
        
        if not config:
            logger.error(f"Persona config for '{persona_key}' not found in prompts.yaml")
            # Fallback (최소한의 설정)
            return {
                "name": "기술 블로거",
                "expertise": "IT 기술 전반",
                "tone": "친절하고 명확하게",
                "category": "IT",
                "default_tags": ["IT", "Tech"],
                "tag_keywords": [],
                "specific_prompt": ""
            }
        return config

class CategoryClassifier:
    KEYWORDS = {
        Persona.SECURITY: ["취약점", "해킹", "보안", "exploit", "security"],
        Persona.AI_ML: ["ai", "ml", "딥러닝", "llm", "gpt", "model"],
        Persona.DEVOPS: ["devops", "ci/cd", "k8s", "docker", "cloud"],
        Persona.CVE_ANALYST: ["cve", "cvss", "poc", "패치"],
    }

    @classmethod
    def classify(cls, article_data: Dict) -> Persona:
        text = (article_data.get("title", "") + " " + article_data.get("summary", "")).lower()
        scores = {p: sum(1 for kw in kws if kw in text) for p, kws in cls.KEYWORDS.items()}
        if not scores or max(scores.values()) == 0:
            return Persona.SECURITY
        return max(scores, key=scores.get)

class TagExtractor:
    @classmethod
    def extract_tags(cls, article_data: Dict, persona: Persona, max_tags: int = 5) -> List[str]:
        config = PersonaConfig.get(persona)
        text = (article_data.get("title", "") + " " + article_data.get("summary", "")).lower()
        tags = []
        for kw in config.get("tag_keywords", []):
            if kw.lower() in text:
                tags.append(kw)
        tags.extend(re.findall(r'CVE-\d{4}-\d{4,7}', text, re.IGNORECASE))
        if len(tags) < max_tags:
            for t in config.get("default_tags", []):
                if t not in tags:
                    tags.append(t)
        return list(dict.fromkeys(tags))[:max_tags]

    @classmethod
    def get_category(cls, persona: Persona) -> str:
        return PersonaConfig.get(persona).get("category", "IT")

class BlogWriter:
    """다중 페르소나 블로그 작성기 (Async)"""

    def __init__(self, client: AsyncLLMClient = None):
        self.client = client or AsyncLLMClient()

    async def generate_article(self, article_data: Dict, persona: Optional[Persona] = None) -> Dict:
        """단일 기사 생성 (비동기) - 2단계 분리 방식"""
        persona = persona or CategoryClassifier.classify(article_data)
        config = PersonaConfig.get(persona)

        category = TagExtractor.get_category(persona)

        # ===== 1단계: 메타데이터 생성 (제목 + 요약 + 태그) =====
        logger.info(f"[Step 1/2] Generating metadata for: {article_data.get('title', 'N/A')[:40]}")
        metadata = await self._generate_metadata(article_data, persona, config, category)

        # ===== 2단계: 본문 생성 (순수 Markdown) =====
        logger.info(f"[Step 2/2] Generating content for: {metadata['title'][:40]}")
        content = await self._generate_content(article_data, metadata, persona, config)

        return {
            "title": metadata['title'],
            "summary": metadata['summary'],
            "content": content,
            "tags": metadata['tags'],
            "category": metadata['category'],
            "persona": persona.value,
            "original_url": article_data.get("url"),
            "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        }

    async def _generate_metadata(self, article_data: Dict, persona: Persona, config: Dict, category: str) -> Dict:
        """1단계: 메타데이터 생성 (제목 + 요약 + 태그)"""
        system_prompt = PromptManager.get("writer_metadata.base_system",
                                        name=config['name'],
                                        expertise=config['expertise'],
                                        tone=config['tone'],
                                        persona_specific=config.get('specific_prompt', ''))

        user_prompt = PromptManager.get("writer_metadata.user",
                                      source=article_data.get('source', 'Unknown'),
                                      title=article_data.get('title', 'N/A'),
                                      url=article_data.get('url', 'N/A'),
                                      summary=article_data.get('summary', 'N/A'),
                                      category=category)

        try:
            response = await self.client.chat(system_prompt, user_prompt)
            return self._parse_metadata_response(response, category)
        except Exception as e:
            logger.error(f"Failed to generate metadata: {e}")
            raise

    async def _generate_content(self, article_data: Dict, metadata: Dict, persona: Persona, config: Dict) -> str:
        """2단계: 본문 생성 (순수 Markdown)"""
        system_prompt = PromptManager.get("writer_content.base_system",
                                        name=config['name'],
                                        expertise=config['expertise'],
                                        tone=config['tone'],
                                        persona_specific=config.get('specific_prompt', ''))

        user_prompt = PromptManager.get("writer_content.user",
                                      title=metadata['title'],
                                      summary=metadata['summary'],
                                      tags=", ".join(metadata['tags']),
                                      source=article_data.get('source', 'Unknown'),
                                      original_title=article_data.get('title', 'N/A'),
                                      url=article_data.get('url', 'N/A'),
                                      original_summary=article_data.get('summary', 'N/A'))

        try:
            response = await self.client.chat(system_prompt, user_prompt)
            # 본문은 순수 마크다운이므로 그대로 사용 (앞뒤 공백 제거)
            content = response.strip()
            # 최소 길이 체크
            if len(content) < 1000:
                logger.warning(f"Generated content too short ({len(content)} chars), may need regeneration")
            return content
        except Exception as e:
            logger.error(f"Failed to generate content: {e}")
            raise

    def _parse_metadata_response(self, response: str, category: str) -> Dict:
        """메타데이터 JSON 응답 파싱"""
        try:
            # JSON 코드 블록 추출
            json_match = re.search(r'```json\s*(\{.*?\})\s*```', response, re.DOTALL)
            json_str = json_match.group(1) if json_match else response

            # JSON 범위 추출
            if '{' in json_str:
                start = json_str.find('{')
                brace_count = 0
                end = start
                for i in range(start, len(json_str)):
                    if json_str[i] == '{':
                        brace_count += 1
                    elif json_str[i] == '}':
                        brace_count -= 1
                        if brace_count == 0:
                            end = i + 1
                            break
                json_str = json_str[start:end]

            # JSON 파싱
            import json as json_lib
            data = json_lib.loads(json_str)

            # 필수 필드 검증
            if not data.get('title'):
                raise ValueError("Missing 'title' field")
            if not data.get('summary'):
                raise ValueError("Missing 'summary' field")
            if not data.get('tags'):
                raise ValueError("Missing 'tags' field")

            return {
                "title": data['title'],
                "summary": data['summary'],
                "tags": data['tags'],
                "category": data.get('category', category)
            }
        except Exception as e:
            logger.error(f"Failed to parse metadata JSON: {e}")
            logger.error(f"Response: {response[:500]}")
            raise

    async def generate_article_batch(self, articles: List[Dict]) -> List[Dict]:
        """여러 기사 병렬 생성"""
        tasks = [self.generate_article(article) for article in articles]
        return await asyncio.gather(*tasks, return_exceptions=True)

    def _parse_result(self, content: str, original: Dict, persona: Persona, category: str, tags: List[str]) -> Dict:
        """Pydantic 모델을 사용한 파싱"""
        # 빈 응답 체크
        if not content or not content.strip():
            logger.error("LLM returned empty response")
            return self._create_fallback(original, persona, category, tags)

        try:
            json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
            json_str = json_match.group(1) if json_match else content

            # JSON 범위 추출 개선
            if '{' in json_str:
                start = json_str.find('{')
                # 중괄호 균형 맞추기
                brace_count = 0
                end = start
                for i in range(start, len(json_str)):
                    if json_str[i] == '{':
                        brace_count += 1
                    elif json_str[i] == '}':
                        brace_count -= 1
                        if brace_count == 0:
                            end = i + 1
                            break
                json_str = json_str[start:end]

            # Pydantic 파싱
            blog_post = BlogPost.model_validate_json(json_str)

            return {
                "title": blog_post.title,
                "summary": blog_post.summary,
                "content": blog_post.content,
                "tags": blog_post.tags or tags,
                "category": blog_post.category or category,
                "persona": persona.value,
                "original_url": original.get("url"),
                "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            }
        except Exception as e:
            logger.warning(f"Pydantic parsing failed ({e}), attempting fallback parsing...")
            return self._create_fallback(original, persona, category, tags, content)

    def _create_fallback(self, original: Dict, persona: Persona, category: str, tags: List[str], llm_response: str = None) -> Dict:
        """Fallback 결과 생성"""
        # LLM 응답에서 JSON 블록 제거 후 content 사용
        content_text = llm_response or ""

        # 디버깅: LLM 원본 응답 로깅
        logger.info(f"[FALLBACK] LLM response length: {len(content_text)}, First 200 chars: {content_text[:200] if content_text else '(EMPTY)'}")

        if content_text:
            # JSON 코드 블록 제거 (전체 JSON 객체 제거, content 필드 추출 아님!)
            # ````json ... ``` 블록 제거 후 그 안의 content만 사용
            json_match = re.search(r'```json\s*(\{.*?\})\s*```', content_text, re.DOTALL)
            if json_match:
                # JSON 문자열에서 content 필드 추출
                json_str = json_match.group(1)
                # 정규식으로 content 필드 값 추출
                content_match = re.search(r'"content"\s*:\s*"((?:[^"\\]|\\.)*)"', json_str, re.DOTALL)
                if content_match:
                    import json as json_lib
                    try:
                        content_value = f'"{content_match.group(1)}"'
                        content_text = json_lib.loads(content_value)
                    except json_lib.JSONDecodeError:
                        content_text = content_match.group(1)
                    except Exception:
                        content_text = content_match.group(1)
                else:
                    content_text = ""
            else:
                # 코드 블록이 없으면 그냥 사용
                content_text = content_text.strip()

        # 여전히 비어있거나 너무 짧으면 원본 요약 사용
        if not content_text or len(content_text) < 100:
            logger.warning(f"[FALLBACK] Content still too short ({len(content_text)}), using original summary")
            content_text = f"# {original.get('title', '제목 없음')}\n\n{original.get('summary', '요약 없음')}\n\n> 본문 생성 실패: 원본 기사를 참고하세요\n\n원본 URL: {original.get('url', '')}"

        logger.info(f"[FALLBACK] Final content length: {len(content_text)}, First 200 chars: {content_text[:200]}")

        return {
            "title": original.get("title"),
            "summary": original.get("summary"),
            "content": content_text,
            "tags": tags,
            "category": category,
            "persona": persona.value,
            "original_url": original.get("url"),
            "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        }

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)