From c92433b0b1e788bda497ce84ac160ae96c25efb9 Mon Sep 17 00:00:00 2001 From: CD Date: Sun, 8 Mar 2026 16:07:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=9E=90=EB=8F=99=ED=99=94=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/anissia_client.py: Anissia API 클라이언트 (편성표/자막) - tools/nyaa_client.py: Nyaa.si RSS 토렌트 검색 - tools/qbit_client.py: qBittorrent Web API 클라이언트 - tools/subtitle_downloader.py: Google Drive/Tistory/Naver 자막 파서 - tools/title_matcher.py: 제목 매칭 + NAS 폴더명 생성 - tools/anime_pipeline.py: 전체 파이프라인 오케스트레이터 - tools/nas_scanner.py: NAS 폴더/파일 스캔 - prompts/unified.md: anime 모드 추가 (AI 평문 의도 분류) - api/discord_bot.py: AI 평문 anime 핸들러 + /anime 슬래시 커맨드 - config.py: qBittorrent/NAS 설정 추가 - .agents/: agent_guide 워크플로우 통합 - docs/devlog: 세션 기록 --- .agents/AGENT.md | 54 ++ .agents/GUIDE.md | 163 ++++ .agents/references/architecture.md | 35 + .agents/references/conventions.md | 45 ++ .agents/references/known-issues.md | 43 + .agents/references/tech-stack.md | 37 + .agents/workflows/check-gitea.md | 40 + .agents/workflows/check-vikunja.md | 41 + .agents/workflows/debug.md | 52 ++ .agents/workflows/end.md | 165 ++++ .agents/workflows/helpers/vikunja_helper.py | 217 +++++ .agents/workflows/helpers/wiki_helper.py | 100 +++ .agents/workflows/pre-task.md | 39 + .agents/workflows/start.md | 65 ++ .gitignore | 3 +- api/discord_bot.py | 838 +++++++++++++++++++- config.py | 10 + core/gemini_caller.py | 15 +- docs/devlog/2026-03-08.md | 5 + docs/devlog/entries/20260308-001.md | 27 + prompts/coder.md | 57 +- prompts/planner.md | 48 +- prompts/reviewer.md | 38 +- prompts/summarizer.md | 18 +- prompts/unified.md | 71 +- run_bot.bat | 14 + tests/test_anime_tools.py | 175 ++++ tools/__init__.py | 1 + tools/anime_pipeline.py | 244 ++++++ tools/anissia_client.py | 120 +++ tools/nas_scanner.py | 152 ++++ tools/nyaa_client.py | 156 ++++ tools/qbit_client.py | 198 +++++ tools/subtitle_downloader.py | 260 ++++++ tools/title_matcher.py | 212 +++++ workspaces.json | 33 +- 36 files changed, 3663 insertions(+), 128 deletions(-) create mode 100644 .agents/AGENT.md create mode 100644 .agents/GUIDE.md create mode 100644 .agents/references/architecture.md create mode 100644 .agents/references/conventions.md create mode 100644 .agents/references/known-issues.md create mode 100644 .agents/references/tech-stack.md create mode 100644 .agents/workflows/check-gitea.md create mode 100644 .agents/workflows/check-vikunja.md create mode 100644 .agents/workflows/debug.md create mode 100644 .agents/workflows/end.md create mode 100644 .agents/workflows/helpers/vikunja_helper.py create mode 100644 .agents/workflows/helpers/wiki_helper.py create mode 100644 .agents/workflows/pre-task.md create mode 100644 .agents/workflows/start.md create mode 100644 docs/devlog/2026-03-08.md create mode 100644 docs/devlog/entries/20260308-001.md create mode 100644 run_bot.bat create mode 100644 tests/test_anime_tools.py create mode 100644 tools/__init__.py create mode 100644 tools/anime_pipeline.py create mode 100644 tools/anissia_client.py create mode 100644 tools/nas_scanner.py create mode 100644 tools/nyaa_client.py create mode 100644 tools/qbit_client.py create mode 100644 tools/subtitle_downloader.py create mode 100644 tools/title_matcher.py diff --git a/.agents/AGENT.md b/.agents/AGENT.md new file mode 100644 index 0000000..148d8b9 --- /dev/null +++ b/.agents/AGENT.md @@ -0,0 +1,54 @@ +--- +description: 모든 작업에 자동 적용되는 에이전트 행동 규칙. 새 대화 시작 시 반드시 이 파일을 먼저 읽습니다. +--- + +# Agent Rules + +## Identity + +당신은 이 프로젝트의 시니어 개발자입니다. 지시를 정확히 따르고, 추측보다 근거를 우선합니다. + +## NEVER (절대 금지) + +1. NEVER start coding without reading relevant reference documents in `.agents/references/` +2. NEVER guess when documentation exists — always check `.agents/references/` first +3. NEVER repeat a failed approach — check `.agents/references/known-issues.md` first +4. NEVER call APIs directly when helper scripts exist in `.agents/workflows/helpers/` +5. NEVER skip the pre-task checklist defined in `.agents/workflows/pre-task.md` +6. NEVER attempt the same failed approach more than 2 times +7. NEVER truncate error messages — always show the full error output + +## ALWAYS (필수) + +1. ALWAYS run `.agents/workflows/pre-task.md` before any implementation task +2. ALWAYS check `.agents/references/known-issues.md` before debugging +3. ALWAYS cite which reference document you consulted and what you learned +4. ALWAYS stop and ask the user if 2 consecutive attempts on the same approach fail +5. ALWAYS use existing helper scripts instead of raw API calls +6. ALWAYS read related existing code (minimum 3 files) before writing new code + +## Failure Protocol + +``` +1st failure → Re-read reference docs → Try DIFFERENT approach +2nd failure (same issue) → STOP → Report diagnosis to user with: + - What was tried + - What failed + - Root cause hypothesis + - Suggested next steps +3rd attempt on same approach → FORBIDDEN +``` + +## Reference Loading Order + +1. `.agents/AGENT.md` (this file — behavior rules) +2. `.agents/references/known-issues.md` (past failure patterns) +3. `.agents/references/` (project-specific knowledge) +4. `.agents/workflows/services.md` (service credentials & protocols) +5. `.agents/workflows/` (action procedures) + +## PowerShell Notes + +- `curl` → PowerShell에서 `Invoke-WebRequest` 별칭. **반드시 `curl.exe`** 사용 +- `npm` → 실행 정책 문제 시 `cmd /c npm` 사용 +- JSON 처리 시 `.py` 스크립트 권장 (PowerShell 이스케이핑 이슈 방지) diff --git a/.agents/GUIDE.md b/.agents/GUIDE.md new file mode 100644 index 0000000..0ec56d6 --- /dev/null +++ b/.agents/GUIDE.md @@ -0,0 +1,163 @@ +# AI 에이전트 워크플로우 시스템 가이드 + +> 이 가이드는 AI 코딩 에이전트가 더 똑똑하게 동작하도록 설계된 범용 워크플로우 시스템의 사용법을 설명합니다. + +--- + +## 왜 이 시스템이 필요한가? + +AI 에이전트는 다음과 같은 문제를 자주 일으킵니다: + +| 문제 | 원인 | +|------|------| +| 📋 워크플로우를 무시함 | 규칙이 강제가 아닌 권고 사항으로만 작성됨 | +| 🔄 같은 실수를 반복함 | 과거 실패 기록을 저장/참조하는 메커니즘 없음 | +| 📖 레퍼런스 문서를 안 읽음 | "읽어라"는 강제 지시가 없고, 어떤 문서를 확인할지 불명확 | +| 🎲 추측으로 시행착오 | 작업 전 체크리스트(Pre-flight Checklist) 부재 | + +이 시스템은 **13회 웹 검색**, **80+ 소스 분석**, **7개 주요 AI 플랫폼**(Claude, GPT, Gemini, Cursor, Cline, Roo, Windsurf) 연구를 기반으로 설계되었습니다. + +--- + +## 파일 구조 개요 + +``` +.agents/ +├── AGENT.md ← 🧠 에이전트 헌법 (NEVER/ALWAYS 규칙) +├── GUIDE.md ← 📖 이 가이드 +├── references/ ← 📚 프로젝트 지식 베이스 +│ ├── architecture.md ← 아키텍처 설명 +│ ├── tech-stack.md ← 기술 스택 & 버전 +│ ├── conventions.md ← 코딩 컨벤션 +│ └── known-issues.md ← 🔴 과거 실패 기록 (핵심!) +└── workflows/ ← ⚙️ 행동 절차 + ├── start.md ← 세션 시작 (룰 로딩 + devlog 복구) + ├── end.md ← 세션 종료 (devlog + known-issues + Vikunja + Git) + ├── pre-task.md ← 작업 전 필수 체크리스트 + ├── debug.md ← 디버깅 전용 절차 + ├── services.md ← 서비스 연동 정보 + AI 작업 프로토콜 + ├── check-gitea.md ← Gitea 현황 조회 + ├── check-vikunja.md ← Vikunja 태스크 조회 + └── helpers/ + ├── vikunja_helper.py ← Vikunja API 안전 래퍼 + └── wiki_helper.py ← Gitea Wiki 래퍼 +``` + +**프로젝트 루트에 자동 생성되는 디렉토리:** +``` +docs/devlog/ ← 📓 세션별 작업 기록 +├── YYYY-MM-DD.md ← Index (매일 1줄씩 누적) +└── entries/ + └── YYYYMMDD-NNN.md ← Entry (설계 결정/미완료 시만) +``` + +--- + +## 각 파일의 역할 + +### 🧠 `AGENT.md` — 에이전트 헌법 + +에이전트가 **모든 대화에서 따라야 하는 글로벌 규칙**입니다. + +**핵심 메커니즘:** +- **NEVER 규칙**: `"절대 ~하지 마라"` — 연구에 따르면 금지 규칙이 더 잘 지켜집니다 +- **Failure Protocol**: 동일 접근 2회 실패 시 자동 중단 → 유저에게 보고 +- **Reference Loading Order**: 어떤 문서를 먼저 읽을지 우선순위 명시 + +### 📋 `pre-task.md` — 사전 점검 체크리스트 + +모든 구현 작업 전에 실행하는 **4단계 체크리스트**: +1. 요구사항 정리 +2. 레퍼런스 확인 (추측 금지) +3. 계획 수립 +4. 유저 확인 + +### 🔴 `known-issues.md` — 과거 실패 기록 + +**가장 중요한 파일.** 에이전트가 같은 실수를 반복하는 근본 원인은 **실패를 기억하지 못하기 때문**입니다. 이 파일은: +- 세션 종료 시 에이전트가 자동으로 새 이슈를 추가 +- 디버깅/구현 전에 에이전트가 반드시 확인 +- 시간이 지날수록 **축적 학습** 효과 + +### 🔧 `debug.md` — 디버깅 전용 워크플로우 + +**추측 기반 디버깅을 금지**하는 5단계 절차: +1. 정보 수집 (에러 전문 확인) +2. known-issues 확인 +3. 근본 원인 분석 (가설 → 검증) +4. 수정 및 검증 +5. 기록 (known-issues에 추가) + +### 📓 Devlog — 세션별 작업 기록 (start.md / end.md에서 관리) + +known-issues가 **실패만** 기록한다면, devlog는 **전체 세션 이력**을 기록합니다: +- **Index** (`docs/devlog/YYYY-MM-DD.md`): 매 작업마다 1줄 (필수) +- **Entry** (`docs/devlog/entries/YYYYMMDD-NNN.md`): 설계 결정/미완료/삽질 시만 (선택) +- **start.md**에서 자동으로 오늘/어제 devlog를 읽어 맥락 복구 + +### ▶️ `start.md` / ⏹️ `end.md` — 세션 관리 + +- **start**: 에이전트 룰 로딩 + devlog 맥락 복구 + Git 상태 + Vikunja TODO +- **end**: known-issues 업데이트 + devlog 기록 + Vikunja 동기화 + Git commit/push + +--- + +## 사용법 + +### 새 프로젝트에 적용하기 + +1. `.agents/` 디렉토리를 프로젝트에 복사 +2. `references/` 파일들을 프로젝트에 맞게 채우기: + - `architecture.md` — 프로젝트 구조 설명 + - `tech-stack.md` — 사용 기술 및 버전 + - `conventions.md` — 코딩 스타일 규칙 +3. 프로젝트별 워크플로우가 있다면 `workflows/`에 추가 + +### 프로젝트별 워크플로우와 함께 사용하기 + +이 범용 워크플로우와 프로젝트별 워크플로우(예: Vikunja 동기화, Gitea 연동)는 **함께 사용**합니다: + +``` +.agents/ +├── AGENT.md ← 범용 (공통) +├── references/ ← 범용 + 프로젝트 특화 +│ ├── known-issues.md ← 범용 (공통) +│ └── ... ← 프로젝트에 맞게 작성 +└── workflows/ + ├── pre-task.md ← 범용 (공통) + ├── debug.md ← 범용 (공통) + ├── start.md ← 범용 기반 + 프로젝트 단계 추가 + ├── end.md ← 범용 기반 + 프로젝트 단계 추가 + ├── services.md ← ⭐ 프로젝트별 + ├── check-vikunja.md ← ⭐ 프로젝트별 + ├── check-gitea.md ← ⭐ 프로젝트별 + └── helpers/ + ├── vikunja_helper.py ← ⭐ 프로젝트별 + └── wiki_helper.py ← ⭐ 프로젝트별 +``` + +### 다른 AI IDE에서도 사용하기 + +| 대상 플랫폼 | 방법 | +|------------|------| +| **Cursor** | `AGENT.md` → `.cursor/rules/agent.mdc` (alwaysApply) | +| **Claude Code** | `AGENT.md` → `CLAUDE.md`, references를 `@import` | +| **Windsurf** | `AGENT.md` → `.windsurfrules` 또는 `.windsurf/rules/agent.md` | +| **Cline/Roo** | 루트에 `AGENTS.md`로 복사 | +| **Gemini** | `AGENT.md` → `.gemini/GEMINI.md` | + +--- + +## 연구 근거 요약 + +이 시스템의 각 설계 결정은 학술 연구와 실무 사례에 근거합니다: + +| 설계 결정 | 근거 | +|----------|------| +| NEVER > ALWAYS (금지 규칙 우선) | Community 검증 — "NEVER use X" ≫ "always prefer Y" | +| 2회 실패 시 자동 중단 | Streak Breaker / Sentinel Check 연구 | +| 실패 기록 누적 | Reflexion Framework (텍스트 피드백 기반 자기 교정) | +| 사전 체크리스트 강제 | Claude Skills 체크리스트 + GPT Chain-of-Thought | +| Progressive Disclosure | Anthropic Context Engineering (2025) | +| 300줄 이하 규칙 | Claude `CLAUDE.md` 공식 권장 (토큰 효율성) | +| 코드 예시 > 설명 | GitHub Copilot Agents, AGENTS.md 공통 Best Practice | diff --git a/.agents/references/architecture.md b/.agents/references/architecture.md new file mode 100644 index 0000000..7cfb37c --- /dev/null +++ b/.agents/references/architecture.md @@ -0,0 +1,35 @@ +# Architecture + +> 이 프로젝트의 아키텍처를 설명하는 문서입니다. +> AI 에이전트는 구현 전 이 문서를 반드시 확인합니다. + +## 프로젝트 개요 + + + +(프로젝트 설명을 여기에 작성하세요) + +## 디렉토리 구조 + +``` +project-root/ +├── src/ # 소스 코드 +├── tests/ # 테스트 +├── docs/ # 문서 +├── .agents/ # AI 에이전트 설정 +└── ... +``` + +## 핵심 모듈 + + + +| 모듈 | 역할 | 의존성 | +|------|------|--------| +| (모듈명) | (역할 설명) | (의존하는 모듈) | + +## 데이터 흐름 + + + +(데이터 흐름을 여기에 작성하세요) diff --git a/.agents/references/conventions.md b/.agents/references/conventions.md new file mode 100644 index 0000000..4ed25ef --- /dev/null +++ b/.agents/references/conventions.md @@ -0,0 +1,45 @@ +# Coding Conventions + +> AI 에이전트는 코드를 작성하기 전 이 컨벤션을 확인합니다. + +## 네이밍 + +| 대상 | 규칙 | 예시 | +|------|------|------| +| 변수/함수 | camelCase | `getUserData()` | +| 클래스 | PascalCase | `UserService` | +| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` | +| 파일명 | kebab-case | `user-service.js` | +| CSS 클래스 | kebab-case | `.nav-header` | + +## 코드 스타일 + +- 들여쓰기: (2 spaces / 4 spaces / tab) +- 세미콜론: (사용 / 미사용) +- 따옴표: (single / double) +- 줄바꿈: LF (Unix style) + +## 커밋 메시지 + +``` +(): + +type: feat|fix|refactor|test|docs|chore|ci|infra +scope: (선택) +``` + +**예시:** +- `feat(server): add WebSocket reconnection logic` +- `fix(frontend): resolve button overlap on mobile` +- `docs: update API documentation` + +## 주석 + +- 한국어/영어 혼용 가능 +- TODO 주석: `// TODO: 설명` 형식 +- 복잡한 로직에는 반드시 WHY(왜) 주석 추가 + +## 테스트 + +- 테스트 파일 위치: (예: `__tests__/` 또는 `*.test.js`) +- 테스트 네이밍: `should [expected behavior] when [condition]` diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md new file mode 100644 index 0000000..b83480c --- /dev/null +++ b/.agents/references/known-issues.md @@ -0,0 +1,43 @@ +# Known Issues & Lessons Learned + +> **이 파일은 SSOT(Single Source of Truth)입니다.** +> 디버깅이나 구현 전에 **반드시** 이 파일을 확인하세요. +> 세션 종료 시 새로 발견된 이슈를 이 파일에 추가합니다. + +--- + +## 포맷 + +각 항목은 아래 형식을 따릅니다: + +```markdown +### [날짜] [키워드] — 한줄 요약 +- **증상**: 무엇이 잘못되었는가 +- **원인**: 근본 원인 +- **해결**: 올바른 해결 방법 +- **주의**: 재발 방지를 위한 교훈 +``` + +--- + +## 공통 이슈 + +### [2026-03-08] PowerShell curl — Invoke-WebRequest 충돌 +- **증상**: `curl` 명령이 예상과 다른 응답 형식을 반환 +- **원인**: PowerShell에서 `curl`은 `Invoke-WebRequest`의 별칭 +- **해결**: **`curl.exe`**를 명시적으로 사용 +- **주의**: HTTP 관련 모든 명령에서 `curl.exe` 사용 필수 + +### [2026-03-08] PowerShell npm — 실행 정책 오류 +- **증상**: `npm run` 명령이 `실행 정책` 관련 오류로 실패 +- **원인**: PowerShell 스크립트 실행 정책이 제한적으로 설정됨 +- **해결**: `cmd /c npm run dev` 형식으로 cmd를 통해 실행 +- **주의**: npm 관련 명령은 항상 `cmd /c` 접두어 사용 권장 + +--- + +## 프로젝트별 이슈 + +> 아래에 프로젝트 특화 이슈를 추가하세요. + +(아직 기록된 프로젝트별 이슈가 없습니다) diff --git a/.agents/references/tech-stack.md b/.agents/references/tech-stack.md new file mode 100644 index 0000000..af6ce80 --- /dev/null +++ b/.agents/references/tech-stack.md @@ -0,0 +1,37 @@ +# Tech Stack + +> AI 에이전트는 구현 전 이 문서를 확인하여 올바른 기술/버전을 사용합니다. + +## 언어 & 런타임 + +| 항목 | 버전 | 비고 | +|------|------|------| +| (예: Node.js) | (예: 20.x) | (설치 경로 등) | +| (예: Python) | (예: 3.12) | (가상환경 경로 등) | + +## 프레임워크 + +| 항목 | 버전 | 용도 | +|------|------|------| +| (예: Express) | (예: 4.18) | (서버) | +| (예: React) | (예: 18.x) | (프론트엔드) | + +## 패키지 관리 + +- 패키지 매니저: (npm / yarn / pnpm / pip 등) +- Lock 파일: (package-lock.json / yarn.lock 등) + +## 개발 도구 + +| 도구 | 명령어 | +|------|--------| +| 개발 서버 | (예: `cmd /c npm run dev`) | +| 빌드 | (예: `cmd /c npm run build`) | +| 테스트 | (예: `cmd /c npm test`) | +| 린트 | (예: `cmd /c npm run lint`) | + +## 환경 변수 + +| 변수명 | 용도 | 기본값 | +|--------|------|--------| +| (예: PORT) | (서버 포트) | (3000) | diff --git a/.agents/workflows/check-gitea.md b/.agents/workflows/check-gitea.md new file mode 100644 index 0000000..ff2143f --- /dev/null +++ b/.agents/workflows/check-gitea.md @@ -0,0 +1,40 @@ +--- +description: Gitea API로 저장소 커밋/이슈/PR 현황을 조회하는 워크플로우 +--- + +# Gitea 저장소 현황 조회 + +서비스 정보는 `.agents/workflows/services.md` 참조. + +// turbo-all + +## 절차 + +1. 최근 커밋 조회 (최신 10개): +```powershell +$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"} +$commits = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/commits?limit=10&sha=main" -Headers $h +$commits | ForEach-Object { Write-Host "$($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" } +``` + +2. 열린 이슈 조회: +```powershell +$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"} +$issues = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/issues?state=open&type=issues" -Headers $h +$issues | ForEach-Object { Write-Host "#$($_.number) $($_.title)" } +``` + +3. Wiki 페이지 목록: +```powershell +python .agents\workflows\helpers\wiki_helper.py list +``` + +4. Wiki 페이지 읽기: +```powershell +python .agents\workflows\helpers\wiki_helper.py read "Architecture" +``` + +5. Wiki 페이지 업데이트: +```powershell +python .agents\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md +``` diff --git a/.agents/workflows/check-vikunja.md b/.agents/workflows/check-vikunja.md new file mode 100644 index 0000000..084cff5 --- /dev/null +++ b/.agents/workflows/check-vikunja.md @@ -0,0 +1,41 @@ +--- +description: Vikunja API로 프로젝트 태스크 현황을 조회하는 워크플로우 +--- + +# Vikunja 태스크 현황 조회 + +서비스 정보는 `.agents/workflows/services.md` 참조. + +// turbo-all + +## 절차 + +1. 전체 목록: +```powershell +python .agents\workflows\helpers\vikunja_helper.py list +``` + +2. TODO만: +```powershell +python .agents\workflows\helpers\vikunja_helper.py list todo +``` + +3. DONE만: +```powershell +python .agents\workflows\helpers\vikunja_helper.py list done +``` + +4. 태스크 완료 처리 (**⚠️ 반드시 이 방법 사용 — 직접 API 호출 금지**): +```powershell +python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID} +``` + +5. 새 태스크 생성: +```powershell +python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High +``` + +> [!CAUTION] +> **절대로** `Invoke-RestMethod -Method Post -Body '{"done": true}'` 같은 직접 API 호출을 사용하지 마세요. +> Vikunja API는 POST 시 body에 포함되지 않은 필드를 빈값으로 덮어씁니다. +> `vikunja_helper.py`는 항상 GET → 기존 필드 보존 → POST 패턴을 사용합니다. diff --git a/.agents/workflows/debug.md b/.agents/workflows/debug.md new file mode 100644 index 0000000..4d701d9 --- /dev/null +++ b/.agents/workflows/debug.md @@ -0,0 +1,52 @@ +--- +description: 에러/버그 발생 시 체계적 디버깅 워크플로우 (에러, 안돼요, 왜 안돼, 버그, 디버그, 수정) +--- + +# Debug Workflow + +> [!IMPORTANT] +> 추측으로 코드를 수정하지 마세요. 반드시 이 순서를 따릅니다. + +## 1단계: 정보 수집 (추측 금지) + +- [ ] 에러 메시지 **전문** 확인 (절대 잘라내지 않기) +- [ ] 관련 로그 파일 확인 +- [ ] 환경 정보 확인 (OS, Node/Python 버전, 의존성 버전 등) +- [ ] 에러가 발생하는 **정확한 입력/조건** 파악 + +## 2단계: Known Issues 확인 + +`.agents/references/known-issues.md`를 읽고 동일하거나 유사한 문제가 있는지 확인합니다. + +> [!CAUTION] +> **known-issues 확인 없이 해결 시도를 시작하지 마세요.** +> 이미 해결된 문제를 다시 삽질하는 것은 시간 낭비입니다. + +## 3단계: 근본 원인 분석 + +- [ ] 에러가 발생하는 **정확한 코드 위치** 확인 +- [ ] 가설을 세우고, 가설을 검증할 수 있는 **최소한의 테스트** 수행 +- [ ] 가설이 틀렸다면 **즉시 다른 가설로 전환** + +> [!WARNING] +> **동일한 접근을 2회 초과 시도하지 마세요.** +> 2회 실패 시 유저에게 보고하고 판단을 요청합니다. +> 보고 내용: 시도한 것 / 실패한 것 / 원인 가설 / 다음 제안 + +## 4단계: 수정 및 검증 + +- [ ] 수정 적용 +- [ ] 동일 에러가 재현되지 않는지 확인 +- [ ] 사이드 이펙트(다른 기능에 영향) 없는지 확인 + +## 5단계: 기록 + +- [ ] `known-issues.md`에 새 항목 추가 (아래 포맷 사용) + +```markdown +### [날짜] [키워드] — 한줄 요약 +- **증상**: 무엇이 잘못되었는가 +- **원인**: 근본 원인 +- **해결**: 올바른 해결 방법 +- **주의**: 재발 방지를 위한 교훈 +``` diff --git a/.agents/workflows/end.md b/.agents/workflows/end.md new file mode 100644 index 0000000..c6df446 --- /dev/null +++ b/.agents/workflows/end.md @@ -0,0 +1,165 @@ +--- +description: 세션 종료 시 devlog 기록 + git commit + Vikunja 동기화 (끝, 마무리, 커밋해, 완료) +--- + +# 세션 종료 프로토콜 + +작업 완료, "끝", "마무리", "커밋해" 등 요청 시 이 워크플로우를 실행합니다. + +// turbo-all + +## 0. 학습 기록 (실패/시행착오 저장) + +이번 세션에서 발생한 실패, 시행착오, 새로 알게 된 사실을 정리합니다: + +- [ ] `.agents/references/known-issues.md`에 추가할 항목이 있는지 확인 +- [ ] 있다면 아래 포맷으로 추가: + +```markdown +### [날짜] [키워드] — 한줄 요약 +- **증상**: ... +- **원인**: ... +- **해결**: ... +- **주의**: ... +``` + +## 1. Devlog 기록 + +### Index 업데이트 (필수 — 매 작업) + +오늘 날짜의 index 파일에 완료된 작업 1줄을 추가합니다. + +- **파일**: `docs/devlog/YYYY-MM-DD.md` +- **형식**: +```markdown +| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 | +``` + +> [!TIP] +> - ✅ = 완료, 🔧 = 미완료 (다음 세션에서 이어받기) +> - 파일이 없으면 새로 생성 (테이블 헤더 포함) + +### Entry 작성 (선택적 — 필요할 때만) + +> [!IMPORTANT] +> Entry는 **git/Vikunja/wiki에 없는 정보**가 있을 때만 작성합니다. + +**Entry 작성 기준:** +- ✅ 설계 결정이 있었을 때 (왜 A가 아닌 B를 선택했는지) +- ✅ 미완료 사항이 있을 때 (다음 세션이 이어받아야 할 맥락) +- ✅ 삽질/트러블슈팅이 있었을 때 (같은 실수 방지) + +**Entry 불필요:** +- ❌ 단순 버그 픽스 (커밋 메시지로 충분) +- ❌ 문서 업데이트 (git diff로 충분) +- ❌ 이미 Vikunja 태스크에 상세 설명이 있는 경우 + +**Entry 파일**: `docs/devlog/entries/YYYYMMDD-NNN.md` +```markdown +# 작업 제목 + +- **시간**: YYYY-MM-DD HH:MM~HH:MM +- **Commit**: `해시` +- **Vikunja**: #태스크번호 → done/진행중 + +## 결정 사항 +- 왜 이 방식을 선택했는지 + +## 미완료 +- 남은 작업 (있을 경우) +``` + +--- + +## 2. Vikunja 동기화 + +> [!CAUTION] +> **반드시 `vikunja_helper.py` 사용.** 직접 API 호출 금지. +> Vikunja API는 POST 시 body에 없는 필드를 빈값으로 덮어씁니다. + +### 2-1. 커밋 전수 검사 + +이번 세션의 **모든 커밋을 하나씩 검사**하고 Vikunja에 매핑합니다. + +```powershell +git log --oneline -20 +``` + +| 커밋 유형 | Vikunja 액션 | +|-----------|-------------| +| 기존 태스크 해당 작업 **완료** | `python .agents\workflows\helpers\vikunja_helper.py done {ID}` | +| 신규 작업 완료 (기존 태스크 없음) | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` | +| 작업 중 발견된 **미완료 TODO** | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` | + +> [!IMPORTANT] +> 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인. + +### 2-2. 완료 처리 + +```powershell +python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID} +``` + +### 2-3. 신규 태스크 생성 + +```powershell +python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High +``` + +### 라벨 규칙 + +**영역 (필수 1개 이상):** `Backend` / `Frontend` / `Engine` / `Infra` / `Test` +**우선순위 (필수 1개):** `Priority:High` / `Priority:Mid` / `Priority:Low` + +--- + +## 3. Wiki 동기화 (해당 시에만) + +| 코드 변경 | 대상 Wiki | +|-----------|----------| +| 서버 변경 | Architecture | +| 프론트엔드 변경 | Architecture | +| 인프라 변경 | Architecture | +| 새 모듈/패키지 추가 | Architecture | + +```powershell +python .agents\workflows\helpers\wiki_helper.py update "Architecture" /tmp/wiki_content.md +``` + +--- + +## 4. Git Commit & Push + +```powershell +git add -A +git status --short +``` +```powershell +git commit -m "커밋 메시지" +``` +```powershell +git push origin main +``` + +**커밋 메시지 컨벤션:** +``` +(): + +type: feat|fix|refactor|test|docs|chore|ci|infra +scope: (선택) +``` + +--- + +## 5. 최종 체크리스트 + +> [!WARNING] +> 아래 항목 중 하나라도 누락되면 세션 종료를 완료할 수 없습니다. + +- [ ] known-issues 업데이트됨 (새 이슈가 있었다면) +- [ ] devlog index 업데이트됨 +- [ ] devlog entry 작성됨 (필요한 경우만) +- [ ] Vikunja 태스크 생성/완료 처리됨 (커밋 전수 검사 기반) +- [ ] Wiki 동기화됨 (아키텍처 변경이 있었다면) +- [ ] git push 완료 +- [ ] 사용자에게 완료 보고 diff --git a/.agents/workflows/helpers/vikunja_helper.py b/.agents/workflows/helpers/vikunja_helper.py new file mode 100644 index 0000000..26e2d10 --- /dev/null +++ b/.agents/workflows/helpers/vikunja_helper.py @@ -0,0 +1,217 @@ +"""Vikunja safe task updater — preserves existing fields when updating tasks. + +Usage: + python vikunja_helper.py done 75 # Mark task #75 as done + python vikunja_helper.py done 71 77 78 # Mark multiple tasks done + python vikunja_helper.py undone 75 # Mark task #75 as not done + python vikunja_helper.py comment 75 "text" # Add comment to task #75 + python vikunja_helper.py desc 75 "text" # Set description (appends if exists) + python vikunja_helper.py create "title" "desc" --labels Backend,Priority:High + python vikunja_helper.py create "title" "desc" --done --labels Frontend,Priority:Mid + python vikunja_helper.py label 75 Backend Priority:High # Add labels to task + python vikunja_helper.py list # List all tasks + python vikunja_helper.py list todo # List TODO only + python vikunja_helper.py list done # List DONE only +""" + +import sys +import json +import urllib.request +import urllib.error +import io + +# Fix Windows console encoding (cp949 → utf-8) +if sys.stdout.encoding != "utf-8": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +# ============================================================ +# ⚙️ CONFIGURATION — PROJECT_ID만 프로젝트별로 변경하세요 +# ============================================================ +API_BASE = "https://plan.variet.net/api/v1" +TOKEN = "tk_070f8e0b715e818bb7178c3815ed5389040eddca" +PROJECT_ID = 0 # ← 프로젝트별 변경 필요 (e.g. 9) +# ============================================================ + +HEADERS = { + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", +} + +# Label name → Vikunja label ID mapping +# Customize for your project's labels +LABEL_MAP = { + "Backend": 1, "Frontend": 2, "Engine": 3, "Infra": 4, "Test": 5, + "Priority:High": 6, "Priority:Mid": 7, "Priority:Low": 8, + "Agent": 17, "Tool": 18, "AI/LLM": 19, +} + + +def api_get(path: str): + req = urllib.request.Request(f"{API_BASE}{path}", headers=HEADERS) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def api_post(path: str, data: dict): + body = json.dumps(data).encode("utf-8") + req = urllib.request.Request(f"{API_BASE}{path}", data=body, headers=HEADERS, method="POST") + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def api_put(path: str, data: dict): + body = json.dumps(data).encode("utf-8") + req = urllib.request.Request(f"{API_BASE}{path}", data=body, headers=HEADERS, method="PUT") + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def get_task(task_id: int) -> dict: + return api_get(f"/tasks/{task_id}") + + +def safe_update_task(task_id: int, updates: dict) -> dict: + task = get_task(task_id) + safe_body = { + "title": task.get("title", ""), + "description": task.get("description", ""), + "priority": task.get("priority", 0), + "done": task.get("done", False), + } + safe_body.update(updates) + return api_post(f"/tasks/{task_id}", safe_body) + + +def mark_done(task_ids: list): + for tid in task_ids: + result = safe_update_task(tid, {"done": True}) + title = result.get("title", "?") + print(f" ✅ #{tid} → done=True [{title}]") + + +def mark_undone(task_ids: list): + for tid in task_ids: + result = safe_update_task(tid, {"done": False}) + title = result.get("title", "?") + print(f" ⬜ #{tid} → done=False [{title}]") + + +def add_comment(task_id: int, comment: str): + result = api_put(f"/tasks/{task_id}/comments", {"comment": comment}) + print(f" 💬 #{task_id} comment added (id={result.get('id', '?')})") + + +def set_description(task_id: int, desc: str, append: bool = True): + task = get_task(task_id) + existing = task.get("description", "") or "" + if append and existing: + new_desc = existing.rstrip() + "\n\n" + desc + else: + new_desc = desc + result = safe_update_task(task_id, {"description": new_desc}) + print(f" 📝 #{task_id} description updated [{result.get('title', '?')}]") + + +def list_tasks(filter_: str = "all"): + all_tasks = [] + page = 1 + while True: + batch = api_get(f"/projects/{PROJECT_ID}/tasks?per_page=50&page={page}") + if not batch: + break + all_tasks.extend(batch) + if len(batch) < 50: + break + page += 1 + + if filter_ == "todo": + all_tasks = [t for t in all_tasks if not t["done"]] + elif filter_ == "done": + all_tasks = [t for t in all_tasks if t["done"]] + + all_tasks.sort(key=lambda t: t["id"]) + for t in all_tasks: + status = "✅" if t["done"] else "⬜" + desc = (t.get("description") or "")[:50].replace("\n", " ") + labels = ", ".join(l["title"] for l in (t.get("labels") or [])) + print(f" {status} #{t['id']:3d} {t['title'][:40]:<40} [{labels}] {desc}") + print(f"\n Total: {len(all_tasks)} tasks") + + +def add_labels(task_id: int, label_names: list): + for name in label_names: + label_id = LABEL_MAP.get(name) + if not label_id: + print(f" ⚠️ Unknown label '{name}'. Valid: {', '.join(LABEL_MAP.keys())}") + continue + try: + api_put(f"/tasks/{task_id}/labels", {"label_id": label_id}) + print(f" 🏷️ #{task_id} + {name} (id={label_id})") + except Exception as e: + if "already" in str(e).lower() or "409" in str(e): + print(f" 🏷️ #{task_id} already has {name}") + else: + print(f" ⚠️ #{task_id} label {name} failed: {e}") + + +def create_task(title: str, description: str = "", done: bool = False, labels: list = None): + payload = {"title": title, "description": description} + result = api_put(f"/projects/{PROJECT_ID}/tasks", payload) + task_id = result["id"] + print(f" ✨ #{task_id} created: {result.get('title', '?')}") + + if labels: + add_labels(task_id, labels) + + if done: + result = safe_update_task(task_id, {"done": True}) + print(f" ✅ #{task_id} → done=True") + + return result + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return + + cmd = sys.argv[1].lower() + + if cmd == "done": + ids = [int(x) for x in sys.argv[2:]] + mark_done(ids) + elif cmd == "undone": + ids = [int(x) for x in sys.argv[2:]] + mark_undone(ids) + elif cmd == "comment": + add_comment(int(sys.argv[2]), sys.argv[3]) + elif cmd == "desc": + set_description(int(sys.argv[2]), sys.argv[3]) + elif cmd == "list": + f = sys.argv[2] if len(sys.argv) > 2 else "all" + list_tasks(f) + elif cmd == "label": + if len(sys.argv) < 4: + print("Usage: vikunja_helper.py label TASK_ID Label1 Label2 ...") + return + add_labels(int(sys.argv[2]), sys.argv[3:]) + elif cmd == "create": + title = sys.argv[2] if len(sys.argv) > 2 else "" + desc = sys.argv[3] if len(sys.argv) > 3 and not sys.argv[3].startswith("--") else "" + is_done = "--done" in sys.argv + labels = None + for i, arg in enumerate(sys.argv): + if arg == "--labels" and i + 1 < len(sys.argv): + labels = sys.argv[i + 1].split(",") + break + if not title: + print("Error: title is required") + return + create_task(title, desc, done=is_done, labels=labels) + else: + print(f"Unknown command: {cmd}") + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/.agents/workflows/helpers/wiki_helper.py b/.agents/workflows/helpers/wiki_helper.py new file mode 100644 index 0000000..d83354e --- /dev/null +++ b/.agents/workflows/helpers/wiki_helper.py @@ -0,0 +1,100 @@ +"""Gitea Wiki helper: list, read, create, update wiki pages. + +Usage: + wiki_helper.py list — list all pages + wiki_helper.py read — read a page + wiki_helper.py create <title> <file> — create a page from file + wiki_helper.py update <title> <file> — update a page from file +""" +import sys, io, json, base64, urllib.request, urllib.error + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +# ============================================================ +# ⚙️ CONFIGURATION — GITEA_REPO만 프로젝트별로 변경하세요 +# ============================================================ +GITEA_BASE_URL = "https://git.variet.net" +GITEA_OWNER = "Variet" +GITEA_REPO = "variet-agent" # ← 프로젝트별 변경 필요 +GITEA_TOKEN = "3a01b4b15a39921572e64c413353e870d4d2161b" +# ============================================================ + +BASE = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/wiki" +HEADERS = {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"} + +def _req(method, path, data=None): + url = f"{BASE}{path}" + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, headers=HEADERS, method=method) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + err = e.read().decode() + print(f" ⚠️ HTTP {e.code}: {err}") + return None + +def _find_sub_url(title): + pages = _req("GET", "/pages") + if pages: + for p in pages: + if p.get("title", "").lower() == title.lower(): + return p.get("sub_url", title) + return title + +def list_pages(): + pages = _req("GET", "/pages") + if pages: + print(f"=== {len(pages)} Wiki Pages ===") + for p in pages: + print(f" {p.get('title', '?')}") + return pages + +def read_page(title): + sub = _find_sub_url(title) + page = _req("GET", f"/page/{sub}") + if page and page.get("content_base64"): + content = base64.b64decode(page["content_base64"]).decode("utf-8") + return content + return None + +def create_page(title, content): + data = { + "title": title, + "content_base64": base64.b64encode(content.encode()).decode(), + } + result = _req("POST", "/new", data) + if result: + print(f" ✅ Created wiki page: {title}") + return result + +def update_page(title, content): + sub = _find_sub_url(title) + data = { + "title": title, + "content_base64": base64.b64encode(content.encode()).decode(), + } + result = _req("PATCH", f"/page/{sub}", data) + if result: + print(f" ✅ Updated wiki page: {title}") + return result + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "list" + + if cmd == "list": + list_pages() + elif cmd == "read" and len(sys.argv) > 2: + content = read_page(sys.argv[2]) + if content: + print(content[:5000]) + else: + print(f" Page '{sys.argv[2]}' not found") + elif cmd == "create" and len(sys.argv) > 3: + with open(sys.argv[3], "r", encoding="utf-8") as f: + create_page(sys.argv[2], f.read()) + elif cmd == "update" and len(sys.argv) > 3: + with open(sys.argv[3], "r", encoding="utf-8") as f: + update_page(sys.argv[2], f.read()) + else: + print("Usage: wiki_helper.py list|read <title>|create <title> <file>|update <title> <file>") diff --git a/.agents/workflows/pre-task.md b/.agents/workflows/pre-task.md new file mode 100644 index 0000000..70c4570 --- /dev/null +++ b/.agents/workflows/pre-task.md @@ -0,0 +1,39 @@ +--- +description: 모든 구현 작업 전 실행하는 사전 점검 체크리스트 (pre-task, 준비, 시작 전, 계획, 구현) +--- + +# Pre-Task Checklist + +> [!IMPORTANT] +> 코딩을 시작하기 전에 반드시 이 체크리스트를 순서대로 완료하세요. +> 체크리스트를 건너뛸 경우 불필요한 시행착오가 발생합니다. + +## 1단계: 요구사항 정리 + +- [ ] 유저 요청을 구체적 작업 항목으로 분해 +- [ ] 변경 범위(scope)를 명확히 정의 (영향받는 파일/모듈) +- [ ] 성공 기준(acceptance criteria) 확인 + +## 2단계: 레퍼런스 확인 (추측 금지) + +- [ ] `.agents/references/architecture.md` — 현재 아키텍처 확인 +- [ ] `.agents/references/tech-stack.md` — 기술 스택 및 버전 확인 +- [ ] `.agents/references/conventions.md` — 코딩 컨벤션 확인 +- [ ] `.agents/references/known-issues.md` — 과거 실패 패턴 확인 +- [ ] 관련 기존 코드 최소 3개 파일 읽기 + +> [!CAUTION] +> 레퍼런스 문서가 존재하는 주제에 대해 추측하지 마세요. +> 문서가 없으면 유저에게 확인을 요청하세요. + +## 3단계: 계획 수립 + +- [ ] 변경할 파일 목록 작성 +- [ ] 의존성 순서 파악 (어떤 파일부터 수정해야 하는가?) +- [ ] 리스크 식별 (어디서 실패할 가능성이 높은가?) +- [ ] 테스트 방법 결정 (어떻게 검증할 것인가?) + +## 4단계: 유저 확인 + +- [ ] 계획을 유저에게 보고하고 승인받기 (변경 파일 3개 이상인 경우) +- [ ] 작은 변경은 바로 실행하되, 변경 내용을 명확히 설명 diff --git a/.agents/workflows/start.md b/.agents/workflows/start.md new file mode 100644 index 0000000..5cb01ee --- /dev/null +++ b/.agents/workflows/start.md @@ -0,0 +1,65 @@ +--- +description: 세션 시작 시 프로젝트 맥락을 빠르게 복구하는 워크플로우 (시작, continue, 이어서, 작업 시작) +--- + +# 세션 시작 프로토콜 + +새 대화 시작, "continue", "이어서", "작업 시작" 등 요청 시 이 워크플로우를 실행합니다. + +// turbo-all + +## 절차 + +### 0. 에이전트 룰 & 맥락 로딩 (자동) + +`.agents/AGENT.md`를 읽고 에이전트 행동 규칙을 로딩합니다. +`.agents/references/known-issues.md`를 읽어 최근 이슈를 파악합니다. + +### 1. Devlog 맥락 복구 + +오늘 + 어제 devlog index를 읽고 최근 작업 흐름을 파악합니다. + +```powershell +$today = Get-Date -Format "yyyy-MM-dd" +$yesterday = (Get-Date).AddDays(-1).ToString("yyyy-MM-dd") +if (Test-Path "docs\devlog\$today.md") { + Write-Host "=== Devlog: $today ===" + Get-Content "docs\devlog\$today.md" +} elseif (Test-Path "docs\devlog\$yesterday.md") { + Write-Host "=== Devlog: $yesterday (no entry for today yet) ===" + Get-Content "docs\devlog\$yesterday.md" +} else { + Write-Host "=== No recent devlog found ===" +} +``` + +미완료(🔧) 항목이 있으면 해당 entry 파일을 읽어 이어받기 맥락을 확보합니다: +- Entry 경로: `docs/devlog/entries/YYYYMMDD-NNN.md` + +### 2. Git 상태 확인 + +```powershell +git status --short +``` +```powershell +git log --oneline -5 +``` + +### 3. Vikunja TODO 태스크 + +```powershell +python .agents\workflows\helpers\vikunja_helper.py list todo +``` + +### 4. 종합 보고 + +결과를 종합하여 사용자에게 보고: +- 마지막 작업 맥락 + 미완료 항목 (devlog 🔧 기반) +- TODO 태스크 목록 (라벨 + 우선순위) +- 다음 작업 제안 + +**우선순위 판단 기준** (라벨만으로 판단 금지): +- P0: 최근 커밋에서 스키마/모델/인터페이스 변경 → 연쇄 영향 점검 +- P1: 서버 기동/API 응답 장애 +- P2: 기능 미완성/UX 개선 +- P3: 정확도 향상, 신규 기능, CI/CD, 문서 정리 diff --git a/.gitignore b/.gitignore index 88f5ea2..24d55e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -# .agent/workflows/services.md 는 토큰이 포함되어 있으므로 제외 +# 토큰 포함 파일 제외 .agent/workflows/services.md +.agents/workflows/services.md sessions/ __pycache__/ *.pyc diff --git a/api/discord_bot.py b/api/discord_bot.py index 25fb1e7..a9832a6 100644 --- a/api/discord_bot.py +++ b/api/discord_bot.py @@ -2,12 +2,15 @@ 슬래시 커맨드로 워크스페이스 관리. 등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답). +/task 커맨드로 프로젝트 선택 → 스레드 자동 생성 → 스레드 내 작업. """ import asyncio import json import logging import re +from datetime import datetime +from pathlib import Path import discord from discord import app_commands @@ -58,6 +61,10 @@ ws_manager = WorkspaceManager() # 실행 중인 작업 추적 (채널ID → asyncio.Task) _running_tasks: dict[int, asyncio.Task] = {} +# 스레드 ↔ 프로젝트 매핑 +_project_threads: dict[str, int] = {} # 프로젝트명 → 활성 스레드 ID +_thread_workspaces: dict[int, "Workspace"] = {} # 스레드 ID → Workspace + # ────────────────────────────────────────────── # 대화 기억 @@ -99,6 +106,23 @@ async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) -> return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n" +async def safe_send_embed(channel, embed: discord.Embed): + """Embed 전송 (description 길이 초과 시 자동 분할).""" + desc = embed.description or "" + if len(desc) <= 4096: + await channel.send(embed=embed) + else: + # 분할 전송 + for i in range(0, len(desc), 4000): + chunk_embed = discord.Embed( + title=embed.title if i == 0 else None, + description=desc[i:i+4000], + color=embed.color, + ) + await channel.send(embed=chunk_embed) + + + # ────────────────────────────────────────────── # 통합 프롬프트 (1회 호출: 분류 + 응답/계획) # ────────────────────────────────────────────── @@ -160,10 +184,16 @@ async def on_ready(): # 슬래시 커맨드 동기화 (길드별 = 즉시 반영) try: + # 1) 글로벌 커맨드를 각 길드로 복사 + 동기화 for guild in bot.guilds: bot.tree.copy_global_to(guild=guild) synced = await bot.tree.sync(guild=guild) logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})") + + # 2) 글로벌 커맨드 제거 (길드 커맨드와 중복 방지) + bot.tree.clear_commands(guild=None) + await bot.tree.sync() + logger.info("글로벌 슬래시 커맨드 정리 완료 (길드 전용)") except Exception as e: logger.error(f"슬래시 커맨드 동기화 실패: {e}") @@ -236,11 +266,15 @@ async def on_message(message: discord.Message): await bot.process_commands(message) return - # 워크스페이스 채널인지 확인 - if not ws_manager.is_workspace_channel(message.channel.id): - return - + # 워크스페이스 채널 또는 스레드 확인 ws = ws_manager.get_workspace(message.channel.id) + if not ws and message.channel.id in _thread_workspaces: + ws = _thread_workspaces[message.channel.id] + # 스레드의 부모 채널이 워크스페이스인 경우 + if not ws and isinstance(message.channel, discord.Thread): + ws = ws_manager.get_workspace(message.channel.parent_id) + if not ws: + return user_text = message.content.strip() if not user_text: return @@ -279,7 +313,11 @@ async def on_message(message: discord.Message): mode = result.get("mode", "chat") logger.info(f"통합 분류: {mode} - \"{user_text[:50]}\"") - if mode == "task": + if mode == "anime": + # 애니메이션 도구 자동 호출 + async with message.channel.typing(): + await _handle_anime(message, result) + elif mode == "task": # Git/Vikunja 미설정 안내 (차단하지 않음) if not ws.is_ready: missing = ws.missing_configs @@ -330,6 +368,284 @@ async def on_message(message: discord.Message): await message.channel.send(embed=embed) +# ────────────────────────────────────────────── +# Anime 핸들러 (AI가 분류한 의도 실행) +# ────────────────────────────────────────────── + +async def _handle_anime(message: discord.Message, parsed: dict): + """AI가 분류한 anime 의도를 실행.""" + from tools.anime_pipeline import AnimePipeline + + action = parsed.get("action", "search") + title = parsed.get("title", "") + episode = parsed.get("episode") + filter_str = parsed.get("filter", "") + summary = parsed.get("summary", "") + + pipeline = AnimePipeline() + + try: + if action == "status": + await _anime_status(message, pipeline) + + elif action == "schedule": + await _anime_schedule(message, pipeline, filter_str) + + elif action == "list": + await _anime_list(message, pipeline, filter_str) + + elif action in ("download", "sub_only", "video_only"): + # 필터에 batch 조건이 있으면 복수 다운로드 + if not title and filter_str: + await _anime_batch(message, pipeline, action, filter_str) + else: + await _anime_download(message, pipeline, title, action, episode) + + else: # search (기본) + if not title: + await message.reply("🔍 어떤 애니를 검색할까요? 제목을 알려주세요.") + return + await _anime_search(message, pipeline, title) + + except Exception as e: + logger.error(f"Anime 핸들러 오류: {e}", exc_info=True) + await message.reply(f"❌ 오류가 발생했습니다: {str(e)[:300]}") + + +async def _anime_search(message, pipeline, title): + """검색 결과 표시.""" + result = await pipeline.search(title) + if not result.anime: + await message.reply(f"❌ '{title}' 검색 결과가 없습니다.") + return + + anime = result.anime + embed = discord.Embed( + title=f"🔍 {anime.subject}", + description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}", + color=0x3498DB, + ) + week_names = ['일','월','화','수','목','금','토','기타'] + embed.add_field(name="📅 편성", value=f"{week_names[anime.week]}요일 {anime.time}", inline=True) + embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True) + + if result.captions: + cap_lines = [] + for c in result.captions[:5]: + url_text = f"[사이트]({c.website})" if c.website else "URL 없음" + cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})") + embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False) + + if result.torrents: + tor_lines = [] + for t in result.torrents[:5]: + ep = f"**{t.episode}화**" if t.episode else "" + tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})") + embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False) + + await safe_send_embed(message.channel, embed) + + +async def _anime_download(message, pipeline, title, mode, episode): + """단일 애니 다운로드.""" + if not title: + await message.reply("📥 어떤 애니를 다운받을까요? 제목을 알려주세요.") + return + + embed = discord.Embed(title="⏳ 처리 중...", description=f"**{title}** 검색 및 다운로드", color=0xF39C12) + status_msg = await message.channel.send(embed=embed) + + result = await pipeline.download(title, mode=mode, episode=episode) + + if result.torrent_added or result.subtitles: + embed.title = "✅ 완료" + embed.color = 0x2ECC71 + else: + embed.title = "⚠️ 부분 완료" + embed.color = 0xF39C12 + + embed.description = result.message[:4000] + await status_msg.edit(embed=embed) + + +async def _anime_batch(message, pipeline, action, filter_str): + """필터 기반 복수 애니 다운로드 (이번분기 자막있는것 등).""" + embed = discord.Embed(title="⏳ 편성표 분석 중...", description="조건에 맞는 애니 검색", color=0xF39C12) + status_msg = await message.channel.send(embed=embed) + + # 전체 편성표 로드 + all_anime = await pipeline.anissia.get_all_schedule() + + # 필터 적용 + filtered = all_anime + if "sub:yes" in filter_str or "자막" in filter_str: + filtered = [a for a in filtered if a.caption_count > 0] + if "quarter:current" in filter_str or "이번" in filter_str: + from datetime import date + today = date.today() + current_q = (today.month - 1) // 3 + 1 + current_year = today.year + def _in_current_quarter(a): + if not a.start_date: + return False + parts = a.start_date.split("-") + y, m = int(parts[0]), int(parts[1]) + q = (m - 1) // 3 + 1 + return y == current_year and q == current_q + filtered = [a for a in filtered if _in_current_quarter(a)] + if "status:on" in filter_str: + filtered = [a for a in filtered if a.status == "ON"] + else: + # 기본: ON 상태만 + filtered = [a for a in filtered if a.status == "ON"] + + embed.title = f"📋 조건 매칭: {len(filtered)}개" + embed.description = "\n".join(f"• {a.subject} (자막 {a.caption_count}명)" for a in filtered[:15]) + if len(filtered) > 15: + embed.description += f"\n... 외 {len(filtered)-15}개" + embed.color = 0x3498DB + await status_msg.edit(embed=embed) + + if not filtered: + return + + # 다운로드 실행 + success_count = 0 + fail_count = 0 + for anime in filtered: + try: + result = await pipeline.download(anime.subject, mode=action) + if result.torrent_added or result.subtitles: + success_count += 1 + else: + fail_count += 1 + except Exception as e: + logger.error(f"배치 다운로드 오류 ({anime.subject}): {e}") + fail_count += 1 + + result_embed = discord.Embed( + title=f"📊 배치 다운로드 결과", + description=f"✅ 성공: {success_count}개\n⚠️ 실패/보류: {fail_count}개", + color=0x2ECC71 if success_count > 0 else 0xF39C12, + ) + await message.channel.send(embed=result_embed) + + +async def _anime_schedule(message, pipeline, filter_str): + """편성표 조회.""" + # 요일 파싱 + week = None + week_map = {"일": 0, "월": 1, "화": 2, "수": 3, "목": 4, "금": 5, "토": 6} + for name, num in week_map.items(): + if name in filter_str: + week = num + break + if "week:" in filter_str: + m = re.search(r'week:(\d)', filter_str) + if m: + week = int(m.group(1)) + + if week is not None: + schedule = await pipeline.anissia.get_schedule(week) + week_names = ['일','월','화','수','목','금','토','기타'] + title = f"📅 {week_names[week]}요일 편성표" + else: + schedule = await pipeline.anissia.get_all_schedule() + schedule = [a for a in schedule if a.status == "ON"] + title = f"📅 이번 분기 방영 중인 애니" + + # 자막 있는것 필터 + if "sub:yes" in filter_str or "자막" in filter_str: + schedule = [a for a in schedule if a.caption_count > 0] + title += " (자막 있음)" + + lines = [] + for a in schedule[:25]: + sub_icon = "📝" if a.caption_count > 0 else " " + lines.append(f"{sub_icon} **{a.subject}** — {a.time} (자막 {a.caption_count}명)") + + embed = discord.Embed( + title=title, + description="\n".join(lines) if lines else "결과 없음", + color=0x3498DB, + ) + if len(schedule) > 25: + embed.set_footer(text=f"총 {len(schedule)}개 중 25개 표시") + await safe_send_embed(message.channel, embed) + + +async def _anime_list(message, pipeline, filter_str): + """NAS에 다운로드된 애니 목록.""" + if not pipeline.nas.is_accessible(): + await message.reply(f"❌ NAS 경로 접근 불가: `{pipeline.nas.base_path}`") + return + + # 분기 필터링 + year, quarter = None, None + if "quarter:current" in filter_str or "이번" in filter_str: + from datetime import date + today = date.today() + year = today.year % 100 + quarter = (today.month - 1) // 3 + 1 + + folders = pipeline.nas.list_anime_folders(year=year, quarter=quarter) + + if not folders: + q_text = f" ({year}년 {quarter}분기)" if year else "" + await message.reply(f"📂 다운로드된 애니가 없습니다{q_text}.") + return + + total_vids = sum(f.video_count for f in folders) + total_subs = sum(f.subtitle_count for f in folders) + total_size = sum(f.total_size_gb for f in folders) + + lines = [] + for f in folders: + sub_icon = "📝" if f.subtitle_count > 0 else "" + lines.append( + f"• **{f.title}** — 🎬{f.video_count}화 {sub_icon}{f.subtitle_count}자막 " + f"({f.total_size_gb:.1f}GB)" + ) + + q_text = f"{year}년 {quarter}분기" if year else "전체" + embed = discord.Embed( + title=f"📂 다운로드된 애니 ({q_text}: {len(folders)}개)", + description="\n".join(lines[:25]), + color=0x2ECC71, + ) + embed.set_footer( + text=f"총 {total_vids}개 영상 | {total_subs}개 자막 | {total_size:.1f}GB" + ) + if len(folders) > 25: + embed.description += f"\n... 외 {len(folders)-25}개" + await safe_send_embed(message.channel, embed) + + +async def _anime_status(message, pipeline): + """qBittorrent 상태 표시.""" + conn = await pipeline.qbit.test_connection() + if not conn.get("connected"): + await message.reply(f"❌ qBittorrent 연결 실패: {conn.get('error', '?')}") + return + + torrents = await pipeline.get_status() + embed = discord.Embed( + title=f"📊 다운로드 큐 ({len(torrents)}건)", + description=f"qBittorrent {conn.get('version', '?')}", + color=0x3498DB, + ) + for t in torrents[:10]: + icon = "✅" if t["progress"] == "100.0%" else "⏳" + embed.add_field( + name=f"{icon} {t['name'][:50]}", + value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}", + inline=False, + ) + if not torrents: + embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False) + await safe_send_embed(message.channel, embed) + + # ────────────────────────────────────────────── # 설정 경고 # ────────────────────────────────────────────── @@ -445,13 +761,25 @@ async def _handle_task(message: discord.Message, text: str, ws): ) await message.channel.send(embed=plan_embed) else: - await message.channel.send( - embed=discord.Embed( - title="⚠️ 실행할 태스크 없음", - description="요청을 더 구체적으로 해주세요.", - color=0xF39C12, + # 태스크가 없지만 summary가 있으면 결과로 표시 (분류 경계 케이스) + summary_text = plan.get("summary", "") or plan.get("result", "") + if summary_text: + await message.channel.send( + embed=discord.Embed( + title="📋 분석 결과", + description=summary_text[:4000], + color=0x3498DB, + ) + ) + pipeline.docs.record_session(text, {"summary": summary_text}, plan) + else: + await message.channel.send( + embed=discord.Embed( + title="⚠️ 실행할 태스크 없음", + description="요청을 더 구체적으로 해주세요.", + color=0xF39C12, + ) ) - ) return # 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증) @@ -593,14 +921,29 @@ async def _handle_task(message: discord.Message, text: str, ws): except GeminiCallError as e: await message.channel.send( - embed=discord.Embed(title="❌ AI 호출 오류", - description=f"```{str(e)[:500]}```", color=0xE74C3C) + embed=discord.Embed( + title="❌ AI 호출 오류", + description=( + f"```{str(e)[:300]}```\n\n" + f"💡 **대응 방법:**\n" + f"• 요청을 더 짧게/구체적으로 다시 시도\n" + f"• 복잡한 요청은 단계별로 나눠서 요청\n" + f"• 잠시 후 다시 시도" + ), + color=0xE74C3C, + ) ) except Exception as e: logger.error(f"작업 오류: {e}", exc_info=True) await message.channel.send( - embed=discord.Embed(title="❌ 오류", - description=f"```{str(e)[:500]}```", color=0xE74C3C) + embed=discord.Embed( + title="❌ 예기치 않은 오류", + description=( + f"```{str(e)[:300]}```\n\n" + f"💡 다시 요청하시거나, 문제가 계속되면 관리자에게 문의하세요." + ), + color=0xE74C3C, + ) ) @@ -786,10 +1129,475 @@ async def workspace_list(interaction: discord.Interaction): bot.tree.add_command(workspace_group) +# ────────────────────────────────────────────── +# /task 커맨드 — 프로젝트 선택 + 스레드 생성 +# ────────────────────────────────────────────── + +class ProjectSelectView(discord.ui.View): + """프로젝트 드롭다운 + 스레드 생성.""" + + def __init__(self, request_text: str): + super().__init__(timeout=60) + self.request_text = request_text + + # 워크스페이스 목록으로 Select 옵션 구성 (channel_id를 value로 사용 — 고유 식별) + options = [] + for ws in ws_manager.list_all(): + label = ws.name[:100] + desc = ws.path[:100] + options.append(discord.SelectOption(label=label, description=desc, value=str(ws.channel_id))) + + if not options: + options.append(discord.SelectOption(label="(등록된 프로젝트 없음)", value="__none__")) + + select = discord.ui.Select( + placeholder="프로젝트를 선택하세요...", + options=options[:25], # Discord 제한 + ) + select.callback = self.on_select + self.add_item(select) + + async def on_select(self, interaction: discord.Interaction): + selected_value = interaction.data["values"][0] + + if selected_value == "__none__": + await interaction.response.send_message( + "❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.", + ephemeral=True, + ) + return + + # channel_id로 워크스페이스 직접 조회 (이름 충돌 방지) + ws = ws_manager.get_workspace(int(selected_value)) + if not ws: + await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True) + return + + # 1) 활성 스레드가 이미 있는지 확인 + if ws.name in _project_threads: + thread_id = _project_threads[ws.name] + try: + thread = interaction.guild.get_thread(thread_id) + if thread and not thread.archived: + # 기존 스레드에 요청 전달 + await interaction.response.send_message( + f"📌 **{ws.name}** 프로젝트는 이미 열린 대화가 있습니다: <#{thread_id}>\n" + f"요청을 해당 스레드에 전달합니다.", + ephemeral=True, + ) + # 스레드에 요청 메시지 전송 + await thread.send( + f"📨 **새 요청** ({interaction.user.display_name}):\n```{self.request_text[:500]}```" + ) + return + else: + # 스레드가 아카이브/삭제됨 → 매핑 정리 + _project_threads.pop(ws.name, None) + _thread_workspaces.pop(thread_id, None) + except Exception: + _project_threads.pop(ws.name, None) + + # 2) 기존 프로젝트 폴더가 있는지 확인 (충돌 체크) + project_path = Path(ws.path) + if project_path.exists() and any(project_path.iterdir()): + # 폴더에 내용물이 있음 → 충돌 해결 필요 + view = ConflictView(ws, self.request_text, interaction) + await interaction.response.send_message( + embed=discord.Embed( + title=f"📂 {ws.name} — 기존 프로젝트 발견", + description=( + f"경로: `{ws.path}`\n\n" + f"기존 프로젝트를 이어가시겠습니까, 새로 시작하시겠습니까?" + ), + color=0xF39C12, + ), + view=view, + ephemeral=True, + ) + return + + # 3) 폴더 없거나 비어있음 → 바로 스레드 생성 + await interaction.response.defer() + await _create_task_thread(interaction, ws, self.request_text) + + +class ConflictView(discord.ui.View): + """기존 프로젝트 이어가기 / 새로 시작 선택.""" + + def __init__(self, ws, request_text: str, original_interaction: discord.Interaction): + super().__init__(timeout=60) + self.ws = ws + self.request_text = request_text + self.original_interaction = original_interaction + + @discord.ui.button(label="🔄 이어가기", style=discord.ButtonStyle.primary) + async def continue_project(self, interaction: discord.Interaction, button: discord.ui.Button): + """기존 프로젝트 폴더로 새 스레드 생성.""" + await interaction.response.defer() + await _create_task_thread(interaction, self.ws, self.request_text) + self.stop() + + @discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary) + async def new_project(self, interaction: discord.Interaction, button: discord.ui.Button): + """기존 폴더 아카이브 + 새 프로젝트 생성.""" + # 기존 폴더 리네임 + old_path = Path(self.ws.path) + suffix = f"_archived_{datetime.now().strftime('%Y%m%d')}" + new_archived_path = old_path.parent / f"{old_path.name}{suffix}" + counter = 1 + while new_archived_path.exists(): + new_archived_path = old_path.parent / f"{old_path.name}{suffix}_{counter}" + counter += 1 + + try: + old_path.rename(new_archived_path) + logger.info(f"프로젝트 아카이브: {old_path} → {new_archived_path}") + except OSError as e: + logger.error(f"폴더 아카이브 실패: {e}") + await interaction.response.send_message(f"❌ 폴더 아카이브 실패: {e}", ephemeral=True) + return + + # 아카이브된 프로젝트를 workspaces에 등록 (접근 유지) + archived_name = new_archived_path.name + archived_ws = ws_manager.set_workspace( + channel_id=-abs(hash(archived_name)) % (10**10), + name=archived_name, + path=str(new_archived_path), + ) + logger.info(f"아카이브 워크스페이스 등록: {archived_name}") + + # 새 폴더 생성 + old_path.mkdir(parents=True, exist_ok=True) + + await interaction.response.defer() + await _create_task_thread(interaction, self.ws, self.request_text) + self.stop() + + +async def _create_task_thread( + interaction: discord.Interaction, + ws, + request_text: str, +): + """스레드를 생성하고 작업을 시작합니다.""" + # 스레드 제목: 프로젝트명 + 요청 앞부분 + thread_name = f"🔧 {ws.name}" + if request_text: + short_req = request_text[:40].replace("\n", " ") + thread_name = f"🔧 {ws.name} — {short_req}" + thread_name = thread_name[:100] # Discord 제한 + + # 스레드 생성 + channel = interaction.channel + thread = await channel.create_thread( + name=thread_name, + type=discord.ChannelType.public_thread, + auto_archive_duration=1440, # 24시간 후 자동 아카이브 + ) + + # 매핑 등록 + _project_threads[ws.name] = thread.id + _thread_workspaces[thread.id] = ws + + logger.info(f"작업 스레드 생성: {thread.name} (ID: {thread.id}) → {ws.name}") + + # 스레드에 시작 메시지 + start_embed = discord.Embed( + title=f"📂 {ws.name}", + description=( + f"경로: `{ws.path}`\n\n" + f"**요청:** {request_text[:500]}\n\n" + f"이 스레드에서 대화를 이어갈 수 있습니다." + ), + color=0x3498DB, + ) + await thread.send(embed=start_embed) + + # followup으로 스레드 안내 + await interaction.followup.send( + f"✅ 스레드가 생성되었습니다: <#{thread.id}>", + ephemeral=True, + ) + + # 작업 실행 (가짜 Message 대신 스레드에 직접 메시지 전송) + if request_text.strip(): + # 통합 프롬프트 호출 + try: + async with thread.typing(): + history = "" + result = await _unified_call(request_text, history, ws.path) + + mode = result.get("mode", "chat") + logger.info(f"[스레드] 통합 분류: {mode} - \"{request_text[:50]}\"") + + if mode == "chat": + response = result.get("response", "응답을 생성하지 못했습니다.") + if len(response) <= 2000: + await thread.send(response) + else: + for i in range(0, len(response), 4000): + chunk = response[i:i + 4000] + embed = discord.Embed(description=chunk, color=0x3498DB) + await thread.send(embed=embed) + elif mode == "clarify": + question = result.get("question", "더 구체적으로 말씀해 주시겠어요?") + embed = discord.Embed( + title="🤔 확인이 필요해요", + description=question, + color=0xF39C12, + ) + await thread.send(embed=embed) + else: # task + # task 모드 — 스레드에서 파이프라인 안내 + await thread.send( + embed=discord.Embed( + title="⚙️ 작업 모드 감지", + description="이 스레드에서 작업 요청을 다시 입력해주세요.\n스레드 내 메시지는 자동으로 처리됩니다.", + color=0xF39C12, + ) + ) + except Exception as e: + logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True) + await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}") + + +@bot.tree.command(name="task", description="프로젝트를 선택하고 작업 요청") +@app_commands.describe(request="작업 요청 내용") +async def task_command(interaction: discord.Interaction, request: str = ""): + """프로젝트 선택 드롭다운 → 스레드 생성 → 작업 시작.""" + all_ws = ws_manager.list_all() + if not all_ws: + await interaction.response.send_message( + "❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.", + ephemeral=True, + ) + return + + view = ProjectSelectView(request) + await interaction.response.send_message( + embed=discord.Embed( + title="📂 프로젝트 선택", + description=( + f"작업할 프로젝트를 선택하세요.\n" + + (f"**요청:** {request[:200]}" if request else "선택 후 스레드에서 요청할 수 있습니다.") + ), + color=0x3498DB, + ), + view=view, + ephemeral=True, + ) + + +# ────────────────────────────────────────────── +# 스레드 이벤트 — 아카이브/삭제 시 매핑 정리 +# ────────────────────────────────────────────── + +@bot.event +async def on_thread_update(before, after): + """스레드 아카이브 감지 → 매핑 정리.""" + if after.archived and after.id in _thread_workspaces: + ws = _thread_workspaces.pop(after.id) + _project_threads.pop(ws.name, None) + logger.info(f"스레드 아카이브 감지 → 매핑 제거: {ws.name} (스레드 {after.id})") + + +@bot.event +async def on_thread_delete(thread): + """스레드 삭제 감지 → 매핑 정리.""" + if thread.id in _thread_workspaces: + ws = _thread_workspaces.pop(thread.id) + _project_threads.pop(ws.name, None) + logger.info(f"스레드 삭제 감지 → 매핑 제거: {ws.name} (스레드 {thread.id})") + + +# ────────────────────────────────────────────── +# /anime 커맨드 — 애니메이션 자동화 +# ────────────────────────────────────────────── + +anime_group = app_commands.Group(name="anime", description="애니메이션 자막/영상 자동화") + + +@anime_group.command(name="search", description="애니 검색 (편성표 + 자막 + 토렌트)") +@app_commands.describe(title="검색할 애니 제목 (한글)") +async def anime_search(interaction: discord.Interaction, title: str): + """Anissia + Nyaa 통합 검색.""" + await interaction.response.defer() + + from tools.anime_pipeline import AnimePipeline + pipeline = AnimePipeline() + + try: + result = await pipeline.search(title) + except Exception as e: + await interaction.followup.send(f"❌ 검색 오류: {e}") + return + + if not result.anime: + await interaction.followup.send(f"❌ '{title}' 검색 결과가 없습니다.") + return + + anime = result.anime + embed = discord.Embed( + title=f"🔍 {anime.subject}", + description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}", + color=0x3498DB, + ) + embed.add_field( + name="📅 편성", + value=f"{['일','월','화','수','목','금','토','기타'][anime.week]}요일 {anime.time}", + inline=True, + ) + embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True) + + # 자막 정보 + if result.captions: + cap_lines = [] + for c in result.captions[:5]: + url_text = f"[사이트]({c.website})" if c.website else "URL 없음" + cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})") + embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False) + else: + embed.add_field(name="📝 자막", value="등록된 자막 없음", inline=False) + + # 토렌트 정보 + if result.torrents: + tor_lines = [] + for t in result.torrents[:5]: + ep = f"**{t.episode}화**" if t.episode else "" + tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})") + embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False) + else: + embed.add_field(name="🎬 토렌트", value="검색 결과 없음", inline=False) + + if result.errors: + embed.set_footer(text="⚠️ " + "; ".join(result.errors)) + + await interaction.followup.send(embed=embed) + + +@anime_group.command(name="download", description="자막+영상 자동 다운로드 (기본: 자막 있으면 영상도)") +@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수 (없으면 최신)") +async def anime_download(interaction: discord.Interaction, title: str, episode: int = None): + """자막+영상 자동 다운로드.""" + await interaction.response.defer() + + from tools.anime_pipeline import AnimePipeline + pipeline = AnimePipeline() + + embed = discord.Embed(title="⏳ 다운로드 진행 중...", description=f"**{title}**", color=0xF39C12) + msg = await interaction.followup.send(embed=embed, wait=True) + + try: + result = await pipeline.download(title, mode="auto", episode=episode) + except Exception as e: + embed.title = "❌ 다운로드 오류" + embed.description = str(e)[:500] + embed.color = 0xE74C3C + await msg.edit(embed=embed) + return + + embed.title = "✅ 다운로드 완료" if result.torrent_added or result.subtitles else "⚠️ 부분 완료" + embed.description = result.message[:4000] + embed.color = 0x2ECC71 if result.torrent_added or result.subtitles else 0xF39C12 + await msg.edit(embed=embed) + + +@anime_group.command(name="sub", description="자막만 다운로드") +@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수") +async def anime_sub(interaction: discord.Interaction, title: str, episode: int = None): + """자막만 다운로드.""" + await interaction.response.defer() + + from tools.anime_pipeline import AnimePipeline + pipeline = AnimePipeline() + + try: + result = await pipeline.download(title, mode="sub_only", episode=episode) + except Exception as e: + await interaction.followup.send(f"❌ 오류: {e}") + return + + embed = discord.Embed( + title=f"📝 자막 다운로드 {'완료' if result.subtitles else '실패'}", + description=result.message[:4000], + color=0x2ECC71 if result.subtitles else 0xE74C3C, + ) + await interaction.followup.send(embed=embed) + + +@anime_group.command(name="video", description="영상만 다운로드 (자막 없어도 강제)") +@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수") +async def anime_video(interaction: discord.Interaction, title: str, episode: int = None): + """영상만 다운로드 (자막 체크 무시).""" + await interaction.response.defer() + + from tools.anime_pipeline import AnimePipeline + pipeline = AnimePipeline() + + try: + result = await pipeline.download(title, mode="video_only", episode=episode) + except Exception as e: + await interaction.followup.send(f"❌ 오류: {e}") + return + + embed = discord.Embed( + title=f"🎬 영상 다운로드 {'추가됨' if result.torrent_added else '실패'}", + description=result.message[:4000], + color=0x2ECC71 if result.torrent_added else 0xE74C3C, + ) + await interaction.followup.send(embed=embed) + + +@anime_group.command(name="status", description="현재 다운로드 큐 상태") +async def anime_status(interaction: discord.Interaction): + """qBittorrent 다운로드 상태 확인.""" + await interaction.response.defer() + + from tools.anime_pipeline import AnimePipeline + pipeline = AnimePipeline() + + # 연결 테스트 + conn = await pipeline.qbit.test_connection() + if not conn.get("connected"): + await interaction.followup.send( + embed=discord.Embed( + title="❌ qBittorrent 연결 실패", + description=f"URL: `{conn.get('url')}`\n오류: {conn.get('error', '?')}", + color=0xE74C3C, + ) + ) + return + + torrents = await pipeline.get_status() + + embed = discord.Embed( + title=f"📊 다운로드 큐 ({len(torrents)}건)", + description=f"qBittorrent {conn.get('version', '?')} | API {conn.get('api_version', '?')}", + color=0x3498DB, + ) + + if torrents: + for t in torrents[:10]: + status_icon = "✅" if t["progress"] == "100.0%" else "⏳" + embed.add_field( + name=f"{status_icon} {t['name'][:50]}", + value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}", + inline=False, + ) + else: + embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False) + + await interaction.followup.send(embed=embed) + + +bot.tree.add_command(anime_group) + + # ────────────────────────────────────────────── # 기존 ! 명령어 (유지, 하위호환) # ────────────────────────────────────────────── + @bot.command(name="ping", help="봇 응답 테스트") async def ping_command(ctx: commands.Context): latency = round(bot.latency * 1000) diff --git a/config.py b/config.py index b1e6d67..8b329cf 100644 --- a/config.py +++ b/config.py @@ -49,3 +49,13 @@ VIKUNJA_PROJECT_ID: int = int(os.getenv("VIKUNJA_PROJECT_ID", "7")) WORKSPACE_BASE_DIR: str = os.getenv( "WORKSPACE_BASE_DIR", r"c:\Users\Certes\Desktop\VW_Proj" ) + +# === qBittorrent === +QBIT_URL: str = os.getenv("QBIT_URL", "http://localhost:8080") +QBIT_USERNAME: str = os.getenv("QBIT_USERNAME", "admin") +QBIT_PASSWORD: str = os.getenv("QBIT_PASSWORD", "") + +# === NAS === +NAS_ANIME_PATH: str = os.getenv( + "NAS_ANIME_PATH", r"\\192.168.10.10\NasData\Video\Animation" +) diff --git a/core/gemini_caller.py b/core/gemini_caller.py index 06d05e8..808e818 100644 --- a/core/gemini_caller.py +++ b/core/gemini_caller.py @@ -97,7 +97,7 @@ class GeminiCaller: # 텍스트 모드 (분류/리뷰/총평) # ────────────────────────────────────────── - async def call(self, role: str, context: str, timeout: int = 120) -> str: + async def call(self, role: str, context: str, timeout: int = 300) -> str: """역할별 프롬프트로 텍스트 생성. 파일 접근 없이 텍스트만 주고받는 역할에 사용. @@ -150,7 +150,8 @@ class GeminiCaller: return result except asyncio.TimeoutError: - raise GeminiCallError(f"Gemini timeout ({timeout}s) -- role={role}") + logger.error(f"Gemini [{role}] 타임아웃 ({timeout}s) — 입력 {len(full_input)}자") + raise GeminiCallError(f"Gemini 응답 시간 초과 ({timeout}초). 요청이 너무 복잡할 수 있습니다.") except FileNotFoundError: raise GeminiCallError("gemini CLI를 찾을 수 없습니다.") except Exception as e: @@ -162,7 +163,7 @@ class GeminiCaller: async def call_agent( self, role: str, context: str, cwd: str, - timeout: int = 300, + timeout: int = 600, ) -> str: """에이전트 모드 — 프로젝트 디렉토리에서 실행. @@ -173,7 +174,7 @@ class GeminiCaller: role: 프롬프트 역할 (coder) context: 작업 지시 (태스크 설명) cwd: 프로젝트 루트 경로 (여기서 Gemini 실행) - timeout: 타임아웃 (에이전트는 더 길게 — 기본 5분) + timeout: 타임아웃 (에이전트는 더 길게 — 기본 10분) """ async with _semaphore: return await self._call_agent_impl(role, context, cwd, timeout) @@ -197,7 +198,8 @@ class GeminiCaller: f"=== IMPORTANT ===\n" f"프로젝트 루트: {cwd}\n" f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n" - f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요." + f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요.\n" + f"모든 응답, 주석, 문서는 반드시 한국어로 작성하세요." ) try: @@ -225,8 +227,9 @@ class GeminiCaller: return result except asyncio.TimeoutError: + logger.error(f"Gemini [{role}] agent 타임아웃 ({timeout}s) — cwd={cwd}") raise GeminiCallError( - f"Gemini agent timeout ({timeout}s) -- role={role}, cwd={cwd}" + f"Gemini 에이전트 응답 시간 초과 ({timeout}초). 작업이 너무 복잡할 수 있습니다." ) except FileNotFoundError: raise GeminiCallError("gemini CLI를 찾을 수 없습니다.") diff --git a/docs/devlog/2026-03-08.md b/docs/devlog/2026-03-08.md new file mode 100644 index 0000000..2c46b27 --- /dev/null +++ b/docs/devlog/2026-03-08.md @@ -0,0 +1,5 @@ +# 2026-03-08 Devlog + +| # | 시간 | 작업 | 커밋 | 상태 | +|---|------|------|------|------| +| 1 | 15:19~16:02 | 애니메이션 자동화 파이프라인 구현 (Anissia/Nyaa/qBit/자막다운로더/NAS스캐너 + AI 평문 통합) | `pending` | ✅ | diff --git a/docs/devlog/entries/20260308-001.md b/docs/devlog/entries/20260308-001.md new file mode 100644 index 0000000..7a1fc4e --- /dev/null +++ b/docs/devlog/entries/20260308-001.md @@ -0,0 +1,27 @@ +# 애니메이션 자동화 파이프라인 구현 + +- **시간**: 2026-03-08 15:19~16:02 +- **Commit**: `pending` + +## 결정 사항 + +- **Tool System 선택**: MCP 대신 프로젝트 내장 tool 모듈 방식 채택 — 기존 variet-agent 프레임워크와 자연스럽게 통합 +- **AI 평문 통합**: 슬래시 커맨드만으로는 AI가 쓰이지 않으므로, 통합 프롬프트에 `anime` 모드를 추가하여 "이번분기 자막있는 애니 다운받아줘" 같은 자연어 요청을 AI가 해석 → 파이프라인 호출 +- **자막 다운로드 전략**: 3개 플랫폼 파서 (Google Drive 60%, Tistory 15%, Naver 20%)로 대부분 커버 +- **제목 매칭**: 일본어→로마자 변환 + fuzzy matching (한자는 변환 불가하나 유사도 0.94+ 달성) + +## 구현 범위 + +- `tools/anissia_client.py` — 편성표/자막 API +- `tools/nyaa_client.py` — RSS 토렌트 검색 +- `tools/qbit_client.py` — qBittorrent Web API +- `tools/subtitle_downloader.py` — 3개 플랫폼 자막 파서 +- `tools/title_matcher.py` — 제목 매칭 + NAS 폴더명 생성 +- `tools/anime_pipeline.py` — 전체 오케스트레이터 +- `tools/nas_scanner.py` — NAS 폴더/파일 스캔 +- `prompts/unified.md` — anime 모드 추가 +- `api/discord_bot.py` — AI 평문 anime 핸들러 + 슬래시 커맨드 + +## 미완료 +- 실제 디스코드 평문 테스트 (봇은 실행되어 있으나 사용자가 아직 테스트하지 않음) +- 자막 다운로드 후 영상 파일명에 맞게 자동 리네이밍 기능은 코드 준비되었으나 파이프라인에서 아직 호출하지 않음 diff --git a/prompts/coder.md b/prompts/coder.md index 017512d..00f0b94 100644 --- a/prompts/coder.md +++ b/prompts/coder.md @@ -1,42 +1,39 @@ -You are a **Coder** — 프로젝트에서 직접 코드를 구현하는 AI 에이전트입니다. +You are a **Coder** — 프로젝트에서 파일을 직접 생성/수정하는 AI 에이전트입니다. + +## 작업 원칙 + +**핵심: 태스크의 description만 보고, 완성된 결과물을 파일로 만드세요.** + +소스코드뿐 아니라 **문서(.md), 설정 파일, 워크플로우, 데이터 파일** 등 모든 유형의 파일을 다룹니다. ## 작업 흐름 -### 1단계: 구현 -- 현재 디렉토리의 프로젝트 파일을 확인 -- 필요한 변경사항을 구현 -- 파일을 직접 생성/수정하여 저장 +### 1단계: 탐색 +- 프로젝트 구조를 먼저 파악하세요 (디렉토리 확인, 관련 파일 검색) +- 기존 프로젝트라면 **관련 파일을 찾아서 읽은 뒤** 수정하세요 +- 빈 프로젝트라면 필요한 파일을 처음부터 만드세요 -### 2단계: 자가 검증 (반드시 수행) -구현 후 다음을 직접 확인하세요: -- 생성/수정한 파일을 다시 읽어서 내용이 완전한지 확인 -- 파일 간 참조(import, src 경로 등)가 올바른지 확인 -- 문법 오류가 없는지 확인 -- 핵심 기능이 빠진 것은 없는지 확인 +### 2단계: 구현 +- 파일을 직접 생성/수정하여 저장하세요 +- 코드블록으로 출력하지 말고, **파일을 직접 만드세요** -### 3단계: 자가 수정 -검증에서 문제를 발견하면: -- 직접 수정하세요 -- 다시 2단계로 돌아가 확인하세요 -- 문제가 없을 때까지 반복하세요 +### 3단계: 자가 검증 (반드시 수행) +구현 후 직접 확인하세요: +- 생성/수정한 파일을 다시 읽어서 내용이 완전한지 +- 파일 간 참조(import, 경로 등)가 올바른지 +- 핵심 내용이 빠진 것은 없는지 -### 4단계: 완료 보고 -모든 검증을 통과한 후에만 완료 보고하세요: +### 4단계: 자가 수정 +검증에서 문제를 발견하면 직접 수정 → 다시 3단계 → 문제 없을 때까지 반복. + +### 5단계: 완료 보고 - 변경한 파일 목록 - 각 파일의 핵심 내용 한 줄 설명 - 자가 검증에서 발견하고 수정한 것이 있으면 언급 +- **실행/사용 방법이 있으면 반드시 안내** (예: 서버 시작 명령, 테스트 방법, 설치 절차 등) ## 규칙 -### 파일 작성 -- 코드블록으로 출력하지 말고, **파일을 직접 생성/수정**하세요 -- 새 프로젝트(빈 폴더)인 경우, 필요한 파일을 모두 처음부터 만드세요 -- 기존 프로젝트인 경우, 기존 구조와 스타일을 유지하세요 - -### 완성도 -- 동작하는 완성된 코드를 작성하세요. 뼈대나 TODO를 남기지 마세요 -- 모든 파일은 실행 가능한 상태여야 합니다 - -### 언어 -- 코드 주석(comment)과 문서(docstring, README 등)는 **한국어**로 작성 -- 변수명, 함수명 등 코드 식별자는 영어 유지 +- 동작하는 완성된 결과물을 만드세요. 뼈대나 TODO를 남기지 마세요. +- 기존 프로젝트의 스타일과 구조를 유지하세요. +- 코드 주석과 문서는 **한국어**로 작성. 코드 식별자는 영어 유지. diff --git a/prompts/planner.md b/prompts/planner.md index b36e901..4f42449 100644 --- a/prompts/planner.md +++ b/prompts/planner.md @@ -1,32 +1,27 @@ -You are a **Planner** — 사용자 요청을 분석하여 직접 처리하거나 태스크로 변환합니다. +You are a **Planner** — 사용자 요청을 분석하여 직접 처리하거나 태스크로 분배합니다. -## 역할 +## 판단 원칙 -사용자의 요청과 프로젝트 컨텍스트를 보고: -1. 무엇을 해야 하는지 분석 -2. **직접 처리할 수 있으면 직접 처리** (파일 삭제, 정리, 간단한 수정 등) -3. 복잡한 작업만 태스크로 분배 +**핵심 질문: "이 작업을 내가 지금 바로 할 수 있는가?"** -## 직접 처리 (direct 모드) +- **Yes** → `direct: true` (직접 처리) +- **No** → `direct: false` + tasks 배열 (코더에게 분배) -다음과 같은 경우 당신이 직접 처리하세요: -- 파일/폴더 삭제, 정리, 이름 변경 -- 간단한 설정 변경, 한두 줄 수정 +### 직접 처리 기준 +- 파일 1-2개 삭제, 이름 변경, 간단한 수정 - 프로젝트 구조 확인, 현황 파악 -- 코더에게 넘기기엔 너무 단순한 작업 +- 간단한 문서(.md, .txt) 생성/수정 +- 에이전트 도구만으로 완료 가능한 작업 -직접 처리 시 에이전트 도구로 파일을 직접 수정한 뒤 결과를 보고하세요. +### 태스크 분배 기준 +- **파일을 생성/수정/삭제해야 하는 모든 작업** (소스코드, 문서, 워크플로우, 설정 파일 등) +- 구현 복잡도가 있어서 코더의 자가 검증이 필요한 작업 +- 1개로 충분하면 **반드시 1개만**. 독립적인 기능이 여러 개일 때만 분할. -## 태스크 분배 (tasks 모드) - -코딩이 필요한 복잡한 작업만 태스크로 만드세요. -**1개로 충분하면 반드시 1개만 만드세요.** - -여러 태스크는 **서로 독립적인 기능이 2개 이상**일 때만. - -절대 하지 말 것: +### ⚠️ 절대 하지 말 것 - 하나의 기능을 "파일 생성", "스타일 추가", "로직 구현"으로 쪼개기 - 단순한 요청을 3개 이상으로 분할하기 +- 작업할 게 없는데 억지로 태스크 만들기 ## 이전 시도 피드백이 있는 경우 @@ -35,7 +30,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석 ## Output Format -### 직접 처리한 경우: +### 직접 처리: ```json { "summary": "처리 결과 요약", @@ -44,7 +39,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석 } ``` -### 태스크 분배가 필요한 경우: +### 태스크 분배: ```json { "summary": "작업 요약", @@ -53,7 +48,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석 { "id": 1, "title": "태스크 제목", - "description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다.", + "description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다. 대상 파일, 내용, 형식을 구체적으로 포함하세요.", "type": "create|modify|delete" } ], @@ -63,6 +58,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석 ## Rules -- description에 모든 구현 세부사항을 적으세요 -- 한국어로 작성하세요 -- 단순한 일을 복잡하게 만들지 마세요 +- description에 **모든 구현 세부사항**을 적으세요. 코더는 이것만 봅니다. +- 한국어로 작성하세요. +- 단순한 일을 복잡하게 만들지 마세요. +- 이전 대화 맥락이 주어지면, 그 내용을 반영하세요. diff --git a/prompts/reviewer.md b/prompts/reviewer.md index 79cd9cb..80dcf7a 100644 --- a/prompts/reviewer.md +++ b/prompts/reviewer.md @@ -1,26 +1,28 @@ -You are a **Reviewer** — 에이전트가 작성한 코드를 리뷰합니다. +You are a **Reviewer** — 에이전트가 수행한 작업의 결과물을 리뷰합니다. -## 입력 +## 리뷰 원칙 -- 요청된 태스크 목록 -- 에이전트의 작업 보고 -- 실제 생성/수정된 파일 내용 +**핵심 질문: "사용자의 요청이 충족되었는가?"** -## 리뷰 기준 +작업 유형에 따라 리뷰 기준을 자율적으로 적용하세요: +- **코드** → 실행 가능 여부, 핵심 기능 구현, 파일 간 참조 정합성 +- **문서** → 내용 완성도, 구조, 요청된 범위 충족 +- **설정/워크플로우** → 형식 유효성, 필수 항목 포함 + +## 통과/반려 기준 ### passed: true (통과) -- 태스크 요구사항을 충족하는 파일이 존재함 -- 코드가 실행 가능한 상태임 (문법 오류 없음) -- 핵심 기능이 구현되어 있음 +- 요청된 결과물이 존재하고, 핵심 내용이 포함됨 +- 명백한 결함이 없음 ### passed: false (반려) -- 비어있거나 잘린 파일이 존재함 -- 핵심 기능이 빠져 있음 -- 명백한 버그가 있음 (런타임 에러 확실) -- 파일 간 참조가 깨져 있음 (예: import 경로 오류) +- 비어있거나 잘린 파일 +- 핵심 기능/내용이 빠져 있음 +- 명백한 버그나 구조적 오류 +- 파일 간 참조가 깨져 있음 ### 반려하지 마세요 -- 스타일이나 코드 품질 문제 (개선 제안으로 남기세요) +- 스타일이나 품질 문제 (개선 제안으로 남기세요) - "더 좋을 수 있는" 부분 - 사소한 미비점 @@ -43,7 +45,7 @@ You are a **Reviewer** — 에이전트가 작성한 코드를 리뷰합니다. ## Rules -- 한국어로 작성하세요 -- **기능 동작 여부**에 집중하세요. 완벽함을 요구하지 마세요. -- critical 이슈가 있을 때만 passed=false -- 의심이 되면 통과시키세요 +- **결과물의 유형에 맞는 기준**으로 리뷰하세요. 모든 작업을 코드 기준으로 보지 마세요. +- critical 이슈가 있을 때만 passed=false. +- 의심이 되면 통과시키세요. +- 한국어로 작성하세요. diff --git a/prompts/summarizer.md b/prompts/summarizer.md index d4edb32..b3998f4 100644 --- a/prompts/summarizer.md +++ b/prompts/summarizer.md @@ -1,13 +1,13 @@ # Summarizer 당신은 AI Agent Team의 **총평 작성자**입니다. -작업 파이프라인이 완료된 후, 전체 결과를 사용자가 이해하기 쉽게 요약합니다. +작업 파이프라인 완료 후, 사용자가 이해하기 쉽게 결과를 요약합니다. ## 입력 - 사용자의 원래 요청 - 태스크 수 -- 에이전트 코딩 결과 보고 +- 에이전트 작업 보고 - 리뷰 결과 ## 출력 형식 (JSON) @@ -16,7 +16,7 @@ { "title": "작업 완료 한줄 제목", "changes": [ - {"file": "path/to/file.py", "description": "변경 내용 설명"} + {"file": "path/to/file", "description": "변경 내용 설명"} ], "warnings": ["주의사항이 있으면 여기에"], "next_steps": ["사용자가 다음에 할 수 있는 작업 제안"], @@ -26,8 +26,10 @@ ## 규칙 -- 기술 용어는 최소화, 사용자 관점에서 서술 -- 한국어로 답변 -- 주의사항이 없으면 warnings를 빈 배열로 -- next_steps는 1-2개만 제안 -- changes의 file은 에이전트 보고에서 언급된 파일명을 사용 +- 사용자 관점에서 서술하세요. 기술 용어는 최소화. +- 한국어로 답변. +- 주의사항이 없으면 warnings를 빈 배열로. +- next_steps는 1-2개만 구체적으로 제안. +- **실행 가능한 결과물이 있으면 next_steps 첫 번째에 실행 방법을 반드시 포함** (예: "터미널에서 `npm start` 실행", "http://localhost:3000 접속" 등). +- changes의 file은 에이전트 보고에서 언급된 파일명 사용. +- 코드 작업과 문서 작업 모두 동일한 형식으로 요약하세요. diff --git a/prompts/unified.md b/prompts/unified.md index 17cd492..dffc126 100644 --- a/prompts/unified.md +++ b/prompts/unified.md @@ -1,29 +1,40 @@ -# Unified Agent — 분류 + 즉답 통합 프롬프트 +# Unified Agent — 분류 + 즉답 -당신은 **Variet Agent**입니다. 사용자의 메시지를 받아 판단하여 즉답하거나 작업으로 넘깁니다. +당신은 **Variet Agent**입니다. 사용자의 메시지를 판단하여 즉답하거나 작업으로 넘깁니다. -## 판단 기준 +## 판단 원칙 -1. **즉답 가능** (질문, 인사, 설명 요청, 의견 교환) - → `mode: "chat"` — 바로 답변을 포함하세요. +**핵심 질문: "이 요청이 무엇을 원하는가?"** -2. **작업 필요** (코드 수정, 파일 생성, 리팩토링, 배포 등 실제 변경이 필요한 요청) - → `mode: "task"` — 작업 요약만 작성하세요. 태스크 분할은 하지 마세요. +- **애니메이션 관련 요청** → `mode: "anime"` (자막, 영상, 다운로드, 편성표 등) +- **프로젝트 파일 변경이 필요** → `mode: "task"` +- **대화로 해결 가능** → `mode: "chat"` +- **판단 불가** → `mode: "clarify"` -3. **불명확** (맥락 부족, 대상 불분명) - → `mode: "clarify"` — 되물을 질문을 포함하세요. +### anime 판단 기준 +다음 키워드/의도가 포함되면 `anime`로 분류: +- 애니메이션/애니 자막 다운로드, 영상 다운로드 +- 편성표 확인, 이번 분기 애니, 신작 +- NAS에 저장, 토렌트, nyaa, 자막 수집 +- 특정 애니 제목 언급 + 다운/검색/모아줘 등 + +### 추가 원칙 +- **확신이 없으면 chat**으로 대응하세요. +- "분석해줘", "제안해줘" 등은 **대부분 대화**입니다. +- "만들어줘", "수정해줘" 등은 문맥을 보세요. 파일/코드 변경이면 task. +- 에러/버그 수정 요청 → **task**. ## 출력 형식 (반드시 JSON) -### 즉답인 경우: +### chat: ```json { "mode": "chat", - "response": "여기에 답변 내용" + "response": "마크다운 형식의 완성된 답변" } ``` -### 작업인 경우: +### task: ```json { "mode": "task", @@ -31,18 +42,46 @@ } ``` -### 불명확한 경우: +### clarify: ```json { "mode": "clarify", - "question": "어떤 파일을 수정할까요?" + "question": "무엇을 명확히 해야 하는지" } ``` +### anime: +```json +{ + "mode": "anime", + "action": "search | download | sub_only | video_only | status | schedule", + "title": "애니 제목 (한글, 가능하면 추출)", + "episode": null, + "filter": "이번분기 자막있는것 등 사용자가 지정한 조건 (없으면 빈 문자열)", + "summary": "사용자 요청 요약" +} +``` + +**anime action 선택 기준:** +- `search`: 검색/정보 확인만 원할 때 +- `download`: 자막+영상 모두 다운 (기본) +- `sub_only`: 자막만 원할 때 +- `video_only`: 영상만 원할 때 +- `status`: 다운로드 진행 상태 확인 +- `schedule`: 편성표/이번분기 목록 조회 +- `list`: NAS에 이미 다운받은 애니 목록 조회 + +**filter 예시:** +- "이번분기 자막있는것" → `"quarter:current sub:yes"` +- "프리렌 7화" → title="장송의 프리렌", episode=7 +- "일요일 편성" → action="schedule", filter="week:0" + ## 규칙 - 반드시 위 JSON 형식만 출력하세요. JSON 외의 텍스트를 포함하지 마세요. -- chat 모드의 response는 마크다운 사용 가능, 완성된 답변이어야 합니다. -- task 모드에서는 summary만 작성하세요. tasks 배열을 만들지 마세요. +- chat의 response는 마크다운 사용 가능, **완성된 답변**이어야 합니다. +- task에서는 summary만 작성하세요. tasks 배열을 만들지 마세요. +- anime에서는 사용자 의도를 정확히 파악하여 action과 파라미터를 설정하세요. - 한국어로 응답하세요. - 이전 대화 기록이 주어지면, 맥락을 고려하세요. + diff --git a/run_bot.bat b/run_bot.bat new file mode 100644 index 0000000..13504d9 --- /dev/null +++ b/run_bot.bat @@ -0,0 +1,14 @@ +@echo off +chcp 65001 >nul +title Variet Agent - Discord Bot + +echo ========================================== +echo Variet Agent 시작 +echo ========================================== +echo. + +C:\ProgramData\miniforge3\envs\agent_chat\python.exe main.py + +echo. +echo 봇이 종료되었습니다. 아무 키나 누르면 창을 닫습니다. +pause >nul diff --git a/tests/test_anime_tools.py b/tests/test_anime_tools.py new file mode 100644 index 0000000..cf89ed6 --- /dev/null +++ b/tests/test_anime_tools.py @@ -0,0 +1,175 @@ +"""애니메이션 도구 테스트 — API 파싱 + 제목 매칭 검증.""" + +import asyncio +import sys +import os +import io + +if sys.stdout.encoding != "utf-8": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +async def test_anissia(): + """Anissia API 테스트.""" + print("=== Anissia API Test ===") + from tools.anissia_client import AnissiaClient + + client = AnissiaClient() + + # 편성표 조회 (일요일) + schedule = await client.get_schedule(0) + print(f" 일요일 편성: {len(schedule)}개") + for a in schedule[:3]: + print(f" [{a.anime_no}] {a.subject} ({a.original_subject}) 자막:{a.caption_count}") + + # 검색 + results = await client.search_anime("프리렌") + print(f" '프리렌' 검색: {len(results)}건") + for a in results: + print(f" {a.subject} — {a.original_subject}") + + # 자막 조회 (첫 번째 결과) + if schedule: + first = schedule[0] + captions = await client.get_captions(first.anime_no) + print(f" '{first.subject}' 자막: {len(captions)}명") + for c in captions: + print(f" {c.name} — {c.episode}화 — {c.website[:50] if c.website else '(없음)'}") + + print(" ✅ Anissia OK\n") + + +async def test_nyaa(): + """Nyaa.si RSS 테스트.""" + print("=== Nyaa RSS Test ===") + from tools.nyaa_client import NyaaClient + + client = NyaaClient() + + results = await client.search("Frieren") + print(f" 'Frieren ASW HEVC' 검색: {len(results)}건") + for t in results[:5]: + ep_str = f"{t.episode}화" if t.episode else "?" + print(f" [{t.group}] {ep_str} {t.size} seeders:{t.seeders}") + print(f" magnet: {t.magnet_link[:60]}...") + + print(" ✅ Nyaa OK\n") + + +async def test_title_matcher(): + """제목 매칭 테스트.""" + print("=== Title Matcher Test ===") + from tools.title_matcher import ( + japanese_to_romaji, normalize_title, title_similarity, + make_nas_folder_name, get_quarter, + ) + + # 로마자 변환 + tests = [ + ("葬送のフリーレン", "sousou no furiren"), + ("鬼滅の刃", "kimetsu no yaiba"), + ("ワンピース", "wanpisu"), + ] + for jp, expected_approx in tests: + romaji = japanese_to_romaji(jp) + print(f" {jp} → {romaji} (기대: ~{expected_approx})") + + # 유사도 계산 + pairs = [ + ("Sousou no Frieren", "sousou no furiren"), + ("Kimetsu no Yaiba", "kimetsu no yaiba"), + ("완전 다른 제목", "completely different"), + ] + for a, b in pairs: + sim = title_similarity(a, b) + print(f" 유사도 '{a}' vs '{b}': {sim:.2f}") + + # NAS 폴더명 + folder = make_nas_folder_name("장송의프리렌 2기", "2026-01-11") + print(f" NAS 폴더: {folder}") + assert folder == "[26_1분기]장송의프리렌 2기", f"Expected [26_1분기]장송의프리렌 2기, got {folder}" + + # 분기 계산 + q_tests = [ + ("2026-01-11", (26, 1)), + ("2026-04-01", (26, 2)), + ("2026-07-15", (26, 3)), + ("2026-10-05", (26, 4)), + ] + for date, expected in q_tests: + result = get_quarter(date) + assert result == expected, f"get_quarter({date}) = {result}, expected {expected}" + print(f" {date} → {result[0]}년 {result[1]}분기 ✓") + + print(" ✅ Title Matcher OK\n") + + +async def test_subtitle_parser(): + """자막 파서 테스트 (HTML 파싱).""" + print("=== Subtitle Parser Test ===") + from tools.subtitle_downloader import ( + parse_google_drive_links, + parse_tistory_links, + parse_naver_links, + ) + + # Google Drive 파싱 + gdrive_html = ''' + <a href="https://drive.google.com/file/d/abc123/view?usp=sharing">1화 자막</a> + <a href="https://drive.google.com/file/d/def456/view">2화 자막</a> + ''' + gd_results = parse_google_drive_links(gdrive_html) + print(f" Google Drive 파싱: {len(gd_results)}건") + for r in gd_results: + print(f" {r.filename} → {r.download_url} (ep={r.episode})") + assert len(gd_results) >= 2, "Google Drive 파싱 실패" + + # Tistory 파싱 + tistory_html = ''' + <a href="https://blog.kakaocdn.net/dna/test/file.zip?credential=abc">file.zip</a> + ''' + ts_results = parse_tistory_links(tistory_html) + print(f" Tistory 파싱: {len(ts_results)}건") + assert len(ts_results) >= 1, "Tistory 파싱 실패" + + # Naver 파싱 + naver_html = ''' + <a class="se-file-save-button" href="https://download.blog.naver.com/path/test.zip">다운로드</a> + ''' + nv_results = parse_naver_links(naver_html) + print(f" Naver 파싱: {len(nv_results)}건") + assert len(nv_results) >= 1, "Naver 파싱 실패" + + print(" ✅ Subtitle Parser OK\n") + + +async def test_qbit_connection(): + """qBittorrent 연결 테스트.""" + print("=== qBittorrent Connection Test ===") + from tools.qbit_client import QBitClient + + client = QBitClient() + result = await client.test_connection() + + if result["connected"]: + print(f" ✅ 연결 성공: v{result['version']} (API {result['api_version']})") + else: + print(f" ⚠️ 연결 실패: {result.get('error', '?')}") + print(f" URL: {result['url']}") + print(" (qBittorrent Web UI가 꺼져있을 수 있음)") + print() + + +async def main(): + await test_title_matcher() + await test_subtitle_parser() + await test_anissia() + await test_nyaa() + await test_qbit_connection() + print("🎉 All tests completed!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..f114d0c --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +# Anime automation tools package. diff --git a/tools/anime_pipeline.py b/tools/anime_pipeline.py new file mode 100644 index 0000000..4864b8a --- /dev/null +++ b/tools/anime_pipeline.py @@ -0,0 +1,244 @@ +"""애니메이션 자동화 파이프라인. + +전체 흐름: +1. Anissia에서 애니 검색 → 자막 정보 확인 +2. Nyaa.si에서 토렌트 검색 → 제목 매칭 +3. qBittorrent에 magnet 추가 → NAS 경로 지정 +4. 자막 다운로드 → 파일명 매칭 +""" + +import asyncio +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import config +from tools.anissia_client import AnissiaClient, AnimeInfo, CaptionInfo +from tools.nyaa_client import NyaaClient, TorrentResult +from tools.qbit_client import QBitClient +from tools.subtitle_downloader import SubtitleDownloader, SubtitleFile +from tools.title_matcher import ( + match_titles, make_nas_folder_name, rename_subtitle_to_video, +) + +logger = logging.getLogger("variet.tools.pipeline") + + +@dataclass +class DownloadResult: + """파이프라인 실행 결과.""" + success: bool + anime: Optional[AnimeInfo] = None + captions: list[CaptionInfo] = field(default_factory=list) + torrents: list[TorrentResult] = field(default_factory=list) + subtitles: list[SubtitleFile] = field(default_factory=list) + nas_folder: str = "" + torrent_added: bool = False + message: str = "" + errors: list[str] = field(default_factory=list) + + +class AnimePipeline: + """애니메이션 다운로드 자동화 파이프라인.""" + + def __init__(self): + self.anissia = AnissiaClient() + self.nyaa = NyaaClient() + self.qbit = QBitClient() + self.sub_downloader = SubtitleDownloader() + self.nas_base = getattr(config, "NAS_ANIME_PATH", + r"\\192.168.10.10\NasData\Video\Animation") + + from tools.nas_scanner import NasScanner + self.nas = NasScanner(self.nas_base) + + async def search(self, title: str) -> DownloadResult: + """애니 검색 — 정보 + 자막 + 토렌트 현황 표시. + + 실제 다운로드 없이 검색 결과만 반환. + """ + result = DownloadResult(success=False) + + # 1. Anissia에서 검색 + try: + anime_list = await self.anissia.search_anime(title) + except Exception as e: + result.errors.append(f"Anissia 검색 오류: {e}") + return result + + if not anime_list: + result.message = f"'{title}' 검색 결과가 없습니다." + return result + + anime = anime_list[0] # 첫 번째 결과 사용 + result.anime = anime + + # 2. 자막 정보 + try: + captions = await self.anissia.get_captions(anime.anime_no) + result.captions = captions + except Exception as e: + result.errors.append(f"자막 조회 오류: {e}") + + # 3. Nyaa 토렌트 검색 (원제 로마자로) + try: + from tools.title_matcher import japanese_to_romaji + romaji_title = japanese_to_romaji(anime.original_subject) + + # 먼저 로마자로 검색 + torrents = await self.nyaa.search(romaji_title) + if not torrents: + # 원제 그대로 검색 + torrents = await self.nyaa.search(anime.original_subject) + + # 제목 매칭 필터링 + matched = match_titles( + anime.subject, anime.original_subject, torrents, threshold=0.3 + ) + result.torrents = matched[:20] # 상위 20개 + except Exception as e: + result.errors.append(f"Nyaa 검색 오류: {e}") + + # NAS 폴더명 생성 + result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date) + + result.success = True + result.message = ( + f"**{anime.subject}** ({anime.original_subject})\n" + f"자막 제작자: {len(result.captions)}명 | " + f"토렌트: {len(result.torrents)}건\n" + f"NAS 폴더: `{result.nas_folder}`" + ) + return result + + async def download( + self, + title: str, + mode: str = "auto", + episode: Optional[int] = None, + ) -> DownloadResult: + """애니 다운로드 실행. + + Args: + title: 한글 제목 + mode: "auto" (자막+영상), "sub_only" (자막만), "video_only" (영상만) + episode: 특정 에피소드만 (None이면 최신) + """ + # 먼저 검색 + result = await self.search(title) + if not result.success: + return result + + anime = result.anime + nas_folder = Path(self.nas_base) / result.nas_folder + + # ── 자막 다운로드 ── + if mode in ("auto", "sub_only"): + await self._download_subtitles(result, nas_folder, episode) + + # ── 영상 토렌트 추가 ── + if mode in ("auto", "video_only"): + force = (mode == "video_only") + await self._add_torrents(result, nas_folder, episode, force=force) + + # 결과 메시지 구성 + parts = [result.message] + if result.subtitles: + parts.append(f"\n📝 자막 {len(result.subtitles)}건 다운로드 완료") + if result.torrent_added: + parts.append(f"\n🎬 토렌트 추가 완료 → `{nas_folder}`") + if result.errors: + parts.append(f"\n⚠️ 오류: " + "; ".join(result.errors)) + + result.message = "\n".join(parts) + return result + + async def _download_subtitles( + self, + result: DownloadResult, + nas_folder: Path, + episode: Optional[int], + ): + """자막 다운로드 처리.""" + sub_dir = nas_folder / "subtitles" + + for caption in result.captions: + if not caption.website: + continue + if episode is not None and caption.episode != str(episode): + continue + + try: + subs = await self.sub_downloader.find_subtitles(caption.website) + for sub in subs: + if episode is not None and sub.episode is not None and sub.episode != episode: + continue + try: + await self.sub_downloader.download_file(sub, str(sub_dir)) + result.subtitles.append(sub) + except Exception as e: + result.errors.append(f"자막 다운로드 실패 ({sub.filename}): {e}") + except Exception as e: + result.errors.append(f"자막 사이트 접근 실패 ({caption.name}): {e}") + + async def _add_torrents( + self, + result: DownloadResult, + nas_folder: Path, + episode: Optional[int], + force: bool = False, + ): + """토렌트 추가 처리.""" + if not result.torrents: + result.errors.append("매칭되는 토렌트가 없습니다.") + return + + # 에피소드 필터링 + candidates = result.torrents + if episode is not None: + candidates = [t for t in candidates if t.episode == episode] + if not candidates: + result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.") + return + + # auto 모드 기본 조건: 자막이 있어야 영상 다운로드 (force면 무시) + if not force and not result.captions and not result.subtitles: + # 자막이 없으면 사용자에게 안내만 + result.errors.append("자막이 없어 영상 다운로드를 보류합니다. /anime video로 강제 다운로드 가능") + return + + # 최상위 1개 (가장 시더 많은) 추가 + best = candidates[0] + try: + success = await self.qbit.add_torrent( + magnet_or_url=best.magnet_link, + save_path=str(nas_folder), + category="anime", + tags=result.anime.subject if result.anime else "", + ) + result.torrent_added = success + if not success: + result.errors.append("qBittorrent 토렌트 추가 실패") + except Exception as e: + result.errors.append(f"qBittorrent 오류: {e}") + + async def get_status(self) -> list[dict]: + """현재 다운로드 큐 상태.""" + try: + torrents = await self.qbit.list_torrents(category="anime") + return [ + { + "name": t.name, + "progress": f"{t.progress * 100:.1f}%", + "state": t.state, + "size": f"{t.size / (1024**3):.2f} GB" if t.size > 0 else "?", + "speed": f"{t.download_speed / (1024**2):.1f} MB/s" if t.download_speed > 0 else "0", + "eta": f"{t.eta // 60}분" if t.eta > 0 else "∞", + "path": t.save_path, + } + for t in torrents + ] + except Exception as e: + logger.error(f"qBittorrent 상태 조회 오류: {e}") + return [] diff --git a/tools/anissia_client.py b/tools/anissia_client.py new file mode 100644 index 0000000..3a4b3dc --- /dev/null +++ b/tools/anissia_client.py @@ -0,0 +1,120 @@ +"""Anissia API 클라이언트 — 애니 편성표 + 자막 정보 조회. + +API Base: https://api.anissia.net +""" + +import httpx +import logging +from dataclasses import dataclass, field +from typing import Optional + +logger = logging.getLogger("variet.tools.anissia") + +BASE_URL = "https://api.anissia.net" + +WEEK_NAMES = { + 0: "일", 1: "월", 2: "화", 3: "수", + 4: "목", 5: "금", 6: "토", 7: "기타", +} + + +@dataclass +class CaptionInfo: + """자막 제작 정보.""" + episode: str + name: str # 제작자 이름 + website: str # 제작자 사이트 URL + updated: str # 업데이트 시각 + + +@dataclass +class AnimeInfo: + """애니메이션 정보.""" + anime_no: int + subject: str # 한글 제목 + original_subject: str # 원어 제목 (일어) + genres: str + week: int + time: str + status: str # ON / OFF + caption_count: int + start_date: str + end_date: str + website: str + twitter: str + + +class AnissiaClient: + """Anissia REST API 클라이언트.""" + + def __init__(self, timeout: float = 15.0): + self._timeout = timeout + + async def get_schedule(self, week: int) -> list[AnimeInfo]: + """요일별 편성표 조회 (week: 0=일 ~ 6=토, 7=기타).""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get(f"{BASE_URL}/anime/schedule/{week}") + resp.raise_for_status() + data = resp.json() + + if data.get("code") != "ok": + raise RuntimeError(f"Anissia API 오류: {data}") + + return [ + AnimeInfo( + anime_no=item["animeNo"], + subject=item["subject"], + original_subject=item.get("originalSubject", ""), + genres=item.get("genres", ""), + week=item.get("week", week) if isinstance(item.get("week"), int) else int(item.get("week", week)), + time=item.get("time", ""), + status=item.get("status", ""), + caption_count=item.get("captionCount", 0), + start_date=item.get("startDate", ""), + end_date=item.get("endDate", ""), + website=item.get("website", ""), + twitter=item.get("twitter", ""), + ) + for item in data["data"] + ] + + async def get_all_schedule(self) -> list[AnimeInfo]: + """전체 요일 편성표 조회 (0~7).""" + all_anime = [] + for week in range(8): + try: + schedule = await self.get_schedule(week) + all_anime.extend(schedule) + except Exception as e: + logger.warning(f"편성표 조회 실패 (week={week}): {e}") + return all_anime + + async def get_captions(self, anime_no: int) -> list[CaptionInfo]: + """특정 애니 자막 목록 조회.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get(f"{BASE_URL}/anime/caption/animeNo/{anime_no}") + resp.raise_for_status() + data = resp.json() + + if data.get("code") != "ok": + raise RuntimeError(f"Anissia caption API 오류: {data}") + + return [ + CaptionInfo( + episode=item.get("episode", ""), + name=item.get("name", ""), + website=item.get("website", ""), + updated=item.get("updDt", ""), + ) + for item in data["data"] + ] + + async def search_anime(self, keyword: str) -> list[AnimeInfo]: + """키워드로 전체 편성표에서 검색 (한글/일어 제목 매칭).""" + all_anime = await self.get_all_schedule() + keyword_lower = keyword.lower() + return [ + a for a in all_anime + if keyword_lower in a.subject.lower() + or keyword_lower in a.original_subject.lower() + ] diff --git a/tools/nas_scanner.py b/tools/nas_scanner.py new file mode 100644 index 0000000..82a1c9f --- /dev/null +++ b/tools/nas_scanner.py @@ -0,0 +1,152 @@ +"""NAS 폴더 스캐너 — 다운로드된 애니 목록 + 파일 정보 조회. + +NAS Animation 폴더 구조: + \\192.168.10.10\NasData\Video\Animation\ + [26_1분기]장송의프리렌2기\ + [ASW] Sousou no Frieren S2 - 07.mkv + subtitles\... + [25_4분기]그노시아\ + ... +""" + +import logging +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import config + +logger = logging.getLogger("variet.tools.nas") + + +@dataclass +class AnimeFolder: + """NAS에 있는 애니 폴더 정보.""" + folder_name: str # [26_1분기]장송의프리렌2기 + full_path: str + title: str # 장송의프리렌2기 + year: int # 26 + quarter: int # 1 + video_count: int = 0 + subtitle_count: int = 0 + total_size_gb: float = 0.0 + video_files: list[str] = field(default_factory=list) + subtitle_files: list[str] = field(default_factory=list) + + +VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".webm", ".m4v"} +SUB_EXTS = {".ass", ".srt", ".ssa", ".sub", ".smi"} + + +def _parse_folder_name(name: str) -> tuple[int, int, str]: + """폴더명에서 연도, 분기, 제목 추출. + + [26_1분기]장송의프리렌2기 → (26, 1, '장송의프리렌2기') + """ + m = re.match(r'\[(\d{2})_(\d)분기\](.+)', name) + if m: + return int(m.group(1)), int(m.group(2)), m.group(3) + return 0, 0, name + + +class NasScanner: + """NAS Animation 폴더 스캐너.""" + + def __init__(self, base_path: str = ""): + self.base_path = Path( + base_path or getattr(config, "NAS_ANIME_PATH", + r"\\192.168.10.10\NasData\Video\Animation") + ) + + def is_accessible(self) -> bool: + """NAS 접근 가능 여부.""" + try: + return self.base_path.exists() and self.base_path.is_dir() + except (OSError, PermissionError): + return False + + def list_anime_folders( + self, + year: Optional[int] = None, + quarter: Optional[int] = None, + ) -> list[AnimeFolder]: + """애니 폴더 목록 조회 (분기별 필터 가능).""" + if not self.is_accessible(): + logger.error(f"NAS 경로 접근 불가: {self.base_path}") + return [] + + results = [] + try: + for entry in sorted(self.base_path.iterdir()): + if not entry.is_dir(): + continue + + y, q, title = _parse_folder_name(entry.name) + + # 필터링 + if year is not None and y != year: + continue + if quarter is not None and q != quarter: + continue + + folder = AnimeFolder( + folder_name=entry.name, + full_path=str(entry), + title=title, + year=y, + quarter=q, + ) + + # 파일 스캔 + self._scan_folder(entry, folder) + results.append(folder) + except (OSError, PermissionError) as e: + logger.error(f"NAS 스캔 오류: {e}") + + return results + + def _scan_folder(self, path: Path, folder: AnimeFolder): + """폴더 내 영상/자막 파일 집계.""" + try: + for item in path.rglob("*"): + if not item.is_file(): + continue + ext = item.suffix.lower() + size = item.stat().st_size + + if ext in VIDEO_EXTS: + folder.video_count += 1 + folder.video_files.append(item.name) + folder.total_size_gb += size / (1024 ** 3) + elif ext in SUB_EXTS: + folder.subtitle_count += 1 + folder.subtitle_files.append(item.name) + except (OSError, PermissionError) as e: + logger.warning(f"파일 스캔 오류 ({path}): {e}") + + def get_current_quarter_anime(self) -> list[AnimeFolder]: + """이번 분기 다운로드된 애니 목록.""" + from datetime import date + today = date.today() + year = today.year % 100 + quarter = (today.month - 1) // 3 + 1 + return self.list_anime_folders(year=year, quarter=quarter) + + def search(self, keyword: str) -> list[AnimeFolder]: + """키워드로 NAS 폴더 검색.""" + all_folders = self.list_anime_folders() + kw = keyword.lower() + return [f for f in all_folders if kw in f.title.lower() or kw in f.folder_name.lower()] + + def get_summary(self, year: Optional[int] = None, quarter: Optional[int] = None) -> dict: + """요약 통계.""" + folders = self.list_anime_folders(year=year, quarter=quarter) + return { + "total_anime": len(folders), + "total_videos": sum(f.video_count for f in folders), + "total_subtitles": sum(f.subtitle_count for f in folders), + "total_size_gb": round(sum(f.total_size_gb for f in folders), 2), + "folders": folders, + } diff --git a/tools/nyaa_client.py b/tools/nyaa_client.py new file mode 100644 index 0000000..765f3b7 --- /dev/null +++ b/tools/nyaa_client.py @@ -0,0 +1,156 @@ +"""Nyaa.si RSS 클라이언트 — 토렌트 검색 + Magnet 링크 생성. + +RSS Feed: https://nyaa.si/?page=rss&q={query}&c={category}&f={filter} +""" + +import httpx +import logging +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Optional +from urllib.parse import quote + +logger = logging.getLogger("variet.tools.nyaa") + +RSS_BASE = "https://nyaa.si/" + +# Nyaa RSS 네임스페이스 +NYAA_NS = "https://nyaa.si/xmlns/nyaa" + + +@dataclass +class TorrentResult: + """Nyaa 토렌트 검색 결과.""" + title: str + torrent_url: str # .torrent 다운로드 URL + magnet_link: str # magnet:?xt=urn:btih:... + info_hash: str + size: str + seeders: int + leechers: int + downloads: int + category: str + pub_date: str + view_url: str # nyaa.si/view/... 페이지 URL + + # 파싱된 정보 + episode: Optional[int] = None + group: str = "" + + +def _parse_episode(title: str) -> Optional[int]: + """제목에서 에피소드 번호 추출. + 예: [ASW] Sousou no Frieren S2 - 07 [1080p ...] → 7 + """ + # 패턴 1: "- 07" 또는 "- 07v2" + m = re.search(r'\s-\s(\d{1,4})(?:v\d)?(?:\s|\[|$)', title) + if m: + return int(m.group(1)) + # 패턴 2: "S02E07" + m = re.search(r'[Ss]\d{1,2}[Ee](\d{1,4})', title) + if m: + return int(m.group(1)) + # 패턴 3: "Episode 07" + m = re.search(r'[Ee]pisode\s*(\d{1,4})', title) + if m: + return int(m.group(1)) + return None + + +def _parse_group(title: str) -> str: + """제목에서 릴리스 그룹명 추출. 예: [ASW] → ASW""" + m = re.match(r'\[([^\]]+)\]', title) + return m.group(1) if m else "" + + +class NyaaClient: + """Nyaa.si RSS 기반 토렌트 검색 클라이언트.""" + + def __init__(self, timeout: float = 15.0, default_suffix: str = "ASW HEVC"): + self._timeout = timeout + self.default_suffix = default_suffix + + async def search( + self, + query: str, + category: str = "0_0", + filter_: int = 0, + use_default_suffix: bool = True, + ) -> list[TorrentResult]: + """RSS 기반 토렌트 검색. + + Args: + query: 검색어 + category: Nyaa 카테고리 (0_0=전체, 1_2=Anime English) + filter_: 필터 (0=없음, 2=trusted only) + use_default_suffix: True면 검색어에 default_suffix 자동 추가 + """ + if use_default_suffix and self.default_suffix: + full_query = f"{query} {self.default_suffix}" + else: + full_query = query + + url = f"{RSS_BASE}?page=rss&q={quote(full_query)}&c={category}&f={filter_}" + logger.info(f"Nyaa RSS 검색: {full_query}") + + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get(url) + resp.raise_for_status() + + return self._parse_rss(resp.text) + + def _parse_rss(self, xml_text: str) -> list[TorrentResult]: + """RSS XML 파싱.""" + root = ET.fromstring(xml_text) + results = [] + + for item in root.findall(".//item"): + title = item.findtext("title", "") + link = item.findtext("link", "") + guid = item.findtext("guid", "") + pub_date = item.findtext("pubDate", "") + + info_hash = item.findtext(f"{{{NYAA_NS}}}infoHash", "") + seeders = int(item.findtext(f"{{{NYAA_NS}}}seeders", "0")) + leechers = int(item.findtext(f"{{{NYAA_NS}}}leechers", "0")) + downloads = int(item.findtext(f"{{{NYAA_NS}}}downloads", "0")) + size = item.findtext(f"{{{NYAA_NS}}}size", "") + category = item.findtext(f"{{{NYAA_NS}}}category", "") + + # Magnet 링크 생성 + magnet = f"magnet:?xt=urn:btih:{info_hash}" if info_hash else "" + + results.append(TorrentResult( + title=title, + torrent_url=link, + magnet_link=magnet, + info_hash=info_hash, + size=size, + seeders=seeders, + leechers=leechers, + downloads=downloads, + category=category, + pub_date=pub_date, + view_url=guid, + episode=_parse_episode(title), + group=_parse_group(title), + )) + + logger.info(f"Nyaa 검색 결과: {len(results)}건") + return results + + async def search_anime( + self, + title: str, + episode: Optional[int] = None, + ) -> list[TorrentResult]: + """애니 제목으로 검색. 에피소드 지정 시 필터링.""" + results = await self.search(title) + + if episode is not None: + results = [r for r in results if r.episode == episode] + + # 시더 수 내림차순 정렬 + results.sort(key=lambda r: r.seeders, reverse=True) + return results diff --git a/tools/qbit_client.py b/tools/qbit_client.py new file mode 100644 index 0000000..490ea48 --- /dev/null +++ b/tools/qbit_client.py @@ -0,0 +1,198 @@ +"""qBittorrent Web API 클라이언트. + +API Docs: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1) +""" + +import httpx +import logging +from dataclasses import dataclass +from typing import Optional + +import config + +logger = logging.getLogger("variet.tools.qbit") + + +@dataclass +class TorrentStatus: + """토렌트 상태.""" + name: str + hash: str + progress: float # 0.0 ~ 1.0 + state: str # downloading, uploading, pausedDL, ... + size: int # bytes + downloaded: int # bytes + upload_speed: int + download_speed: int + eta: int # seconds, -1 = unknown + save_path: str + category: str + + +class QBitClient: + """qBittorrent Web API 클라이언트.""" + + def __init__( + self, + url: str = None, + username: str = None, + password: str = None, + ): + self.url = (url or getattr(config, "QBIT_URL", "http://localhost:8080")).rstrip("/") + self.username = username or getattr(config, "QBIT_USERNAME", "admin") + self.password = password or getattr(config, "QBIT_PASSWORD", "") + self._sid: Optional[str] = None + + async def login(self) -> bool: + """로그인 → SID 쿠키 획득.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{self.url}/api/v2/auth/login", + data={"username": self.username, "password": self.password}, + ) + if resp.text.strip().lower() == "ok.": + self._sid = resp.cookies.get("SID") + logger.info("qBittorrent 로그인 성공") + return True + else: + logger.error(f"qBittorrent 로그인 실패: {resp.text}") + return False + + def _cookies(self) -> dict: + return {"SID": self._sid} if self._sid else {} + + async def _ensure_login(self): + if not self._sid: + if not await self.login(): + raise RuntimeError("qBittorrent 로그인 실패") + + async def add_torrent( + self, + magnet_or_url: str, + save_path: str = "", + category: str = "anime", + tags: str = "", + ) -> bool: + """토렌트 추가 (magnet 링크 또는 .torrent URL). + + Args: + magnet_or_url: magnet 링크 또는 .torrent URL + save_path: 저장 경로 (미지정 시 qBittorrent 기본) + category: 카테고리 + tags: 태그 (쉼표 구분) + """ + await self._ensure_login() + + data = { + "urls": magnet_or_url, + "category": category, + } + if save_path: + data["savepath"] = save_path + if tags: + data["tags"] = tags + + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{self.url}/api/v2/torrents/add", + data=data, + cookies=self._cookies(), + ) + + if resp.text.strip().lower() == "ok.": + logger.info(f"토렌트 추가 성공: {magnet_or_url[:60]}... → {save_path}") + return True + else: + logger.error(f"토렌트 추가 실패: {resp.text}") + return False + + async def get_torrent_status(self, info_hash: str) -> Optional[TorrentStatus]: + """특정 토렌트 상태 조회.""" + await self._ensure_login() + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"{self.url}/api/v2/torrents/info", + params={"hashes": info_hash}, + cookies=self._cookies(), + ) + resp.raise_for_status() + data = resp.json() + + if not data: + return None + + t = data[0] + return TorrentStatus( + name=t.get("name", ""), + hash=t.get("hash", ""), + progress=t.get("progress", 0), + state=t.get("state", ""), + size=t.get("total_size", 0), + downloaded=t.get("downloaded", 0), + upload_speed=t.get("upspeed", 0), + download_speed=t.get("dlspeed", 0), + eta=t.get("eta", -1), + save_path=t.get("save_path", ""), + category=t.get("category", ""), + ) + + async def list_torrents(self, category: str = "") -> list[TorrentStatus]: + """토렌트 목록 조회.""" + await self._ensure_login() + + params = {} + if category: + params["category"] = category + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"{self.url}/api/v2/torrents/info", + params=params, + cookies=self._cookies(), + ) + resp.raise_for_status() + data = resp.json() + + return [ + TorrentStatus( + name=t.get("name", ""), + hash=t.get("hash", ""), + progress=t.get("progress", 0), + state=t.get("state", ""), + size=t.get("total_size", 0), + downloaded=t.get("downloaded", 0), + upload_speed=t.get("upspeed", 0), + download_speed=t.get("dlspeed", 0), + eta=t.get("eta", -1), + save_path=t.get("save_path", ""), + category=t.get("category", ""), + ) + for t in data + ] + + async def test_connection(self) -> dict: + """연결 테스트 — 버전 정보 반환.""" + try: + await self._ensure_login() + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"{self.url}/api/v2/app/version", + cookies=self._cookies(), + ) + version = resp.text.strip() + + resp2 = await client.get( + f"{self.url}/api/v2/app/webapiVersion", + cookies=self._cookies(), + ) + api_version = resp2.text.strip() + + return { + "connected": True, + "version": version, + "api_version": api_version, + "url": self.url, + } + except Exception as e: + return {"connected": False, "error": str(e), "url": self.url} diff --git a/tools/subtitle_downloader.py b/tools/subtitle_downloader.py new file mode 100644 index 0000000..f6b95c5 --- /dev/null +++ b/tools/subtitle_downloader.py @@ -0,0 +1,260 @@ +"""자막 파일 다운로더 — 3개 플랫폼 파서. + +지원 플랫폼: +1. Google Drive (Blogspot 제작자 대부분) +2. Tistory (Kakao CDN 직접 다운로드) +3. Naver Blog (네이티브 첨부파일) +""" + +import httpx +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional +from urllib.parse import unquote + +logger = logging.getLogger("variet.tools.subtitle") + + +@dataclass +class SubtitleFile: + """다운로드된/발견된 자막 파일 정보.""" + filename: str + download_url: str + platform: str # google_drive, tistory, naver + episode: Optional[int] = None + local_path: Optional[str] = None # 다운로드 후 로컬 경로 + + +def _extract_episode_from_text(text: str) -> Optional[int]: + """텍스트에서 화수 추출.""" + # "9화", "09화", "9 화" + m = re.search(r'(\d{1,4})\s*화', text) + if m: + return int(m.group(1)) + # "- 09" + m = re.search(r'[-–]\s*(\d{1,4})(?:\s|$|\.)', text) + if m: + return int(m.group(1)) + # "Episode 9", "EP09" + m = re.search(r'(?:EP|Episode)\s*(\d{1,4})', text, re.IGNORECASE) + if m: + return int(m.group(1)) + return None + + +# ────────────────────────────────────────────── +# 1. Google Drive 파서 +# ────────────────────────────────────────────── + +def parse_google_drive_links(html: str) -> list[SubtitleFile]: + """HTML에서 Google Drive 다운로드 링크 추출. + + 패턴: drive.google.com/file/d/{fileId}/view + → 직접 다운로드: drive.google.com/uc?id={fileId}&export=download + """ + pattern = r'https://drive\.google\.com/file/d/([a-zA-Z0-9_-]+)/view[^"]*' + matches = re.findall(pattern, html) + + # 링크 주변 텍스트에서 에피소드 정보 추출 + link_pattern = r'<a[^>]*href="(https://drive\.google\.com/file/d/[^"]+)"[^>]*>([^<]*)</a>' + link_matches = re.findall(link_pattern, html) + + results = [] + seen_ids = set() + + for url, text in link_matches: + m = re.search(r'/d/([a-zA-Z0-9_-]+)/', url) + if not m: + continue + file_id = m.group(1) + if file_id in seen_ids: + continue + seen_ids.add(file_id) + + episode = _extract_episode_from_text(text) + download_url = f"https://drive.google.com/uc?id={file_id}&export=download" + + results.append(SubtitleFile( + filename=text.strip() or f"subtitle_{file_id}", + download_url=download_url, + platform="google_drive", + episode=episode, + )) + + # 매칭되지 않은 bare ID도 추가 + for file_id in matches: + if file_id not in seen_ids: + seen_ids.add(file_id) + results.append(SubtitleFile( + filename=f"subtitle_{file_id}", + download_url=f"https://drive.google.com/uc?id={file_id}&export=download", + platform="google_drive", + )) + + return results + + +# ────────────────────────────────────────────── +# 2. Tistory 파서 +# ────────────────────────────────────────────── + +def parse_tistory_links(html: str) -> list[SubtitleFile]: + """HTML에서 Tistory/Kakao CDN 다운로드 링크 추출. + + 패턴: blog.kakaocdn.net/dna/.../filename.zip?... + """ + pattern = r'(https://blog\.kakaocdn\.net/[^"]+\.(zip|ass|srt|ssa|sub)[^"]*)' + matches = re.findall(pattern, html, re.IGNORECASE) + + results = [] + for url, ext in matches: + # URL에서 파일명 추출 + name_match = re.search(r'/([^/?]+\.' + ext + r')', unquote(url)) + filename = name_match.group(1) if name_match else f"subtitle.{ext}" + + episode = _extract_episode_from_text(filename) + + results.append(SubtitleFile( + filename=filename, + download_url=url, + platform="tistory", + episode=episode, + )) + + return results + + +# ────────────────────────────────────────────── +# 3. Naver Blog 파서 +# ────────────────────────────────────────────── + +def parse_naver_links(html: str) -> list[SubtitleFile]: + """HTML에서 Naver Blog 첨부파일 다운로드 링크 추출. + + 패턴: download.blog.naver.com/... 또는 blogfiles.pstatic.net/... + """ + results = [] + + # Naver 파일 다운로드 버튼 + # <a class="se-file-save-button" href="https://download.blog.naver.com/..." ...> + file_pattern = r'href="(https://(?:download\.blog\.naver\.com|blogfiles\.pstatic\.net)/[^"]+)"' + matches = re.findall(file_pattern, html) + + for url in matches: + # URL에서 파일명 추출 + decoded = unquote(url) + name_match = re.search(r'/([^/?]+\.(?:zip|ass|srt|ssa|sub|7z))', decoded, re.IGNORECASE) + filename = name_match.group(1) if name_match else "subtitle_naver" + + episode = _extract_episode_from_text(filename) + + results.append(SubtitleFile( + filename=filename, + download_url=url, + platform="naver", + episode=episode, + )) + + return results + + +# ────────────────────────────────────────────── +# 통합 다운로더 +# ────────────────────────────────────────────── + +class SubtitleDownloader: + """자막 파일 검색 및 다운로드.""" + + def __init__(self, download_dir: str = ""): + self.download_dir = Path(download_dir) if download_dir else Path.cwd() / "subtitles" + + async def fetch_page(self, url: str) -> str: + """웹 페이지 HTML 가져오기.""" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept-Language": "ko-KR,ko;q=0.9", + } + + # Naver Blog iframe bypass + if "blog.naver.com" in url and "PostView" not in url: + # blog.naver.com/{blogId}/{logNo} → PostView URL + m = re.search(r'blog\.naver\.com/([^/]+)/(\d+)', url) + if m: + url = f"https://blog.naver.com/PostView.naver?blogId={m.group(1)}&logNo={m.group(2)}" + + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + return resp.text + + async def find_subtitles(self, url: str) -> list[SubtitleFile]: + """URL에서 자막 파일 링크 자동 탐지.""" + html = await self.fetch_page(url) + + results = [] + + # 플랫폼 자동 감지 후 파싱 + if "drive.google.com" in html: + results.extend(parse_google_drive_links(html)) + + if "blog.kakaocdn.net" in html: + results.extend(parse_tistory_links(html)) + + if "download.blog.naver.com" in html or "blogfiles.pstatic.net" in html: + results.extend(parse_naver_links(html)) + + # 범용: 직접 자막 파일 링크 탐지 + generic_pattern = r'href="([^"]+\.(?:ass|srt|ssa|sub|zip|7z))"' + generic = re.findall(generic_pattern, html, re.IGNORECASE) + seen_urls = {r.download_url for r in results} + for gurl in generic: + if gurl not in seen_urls: + filename = gurl.split("/")[-1].split("?")[0] + results.append(SubtitleFile( + filename=unquote(filename), + download_url=gurl, + platform="generic", + episode=_extract_episode_from_text(filename), + )) + + logger.info(f"자막 {len(results)}건 발견: {url}") + return results + + async def download_file( + self, + sub: SubtitleFile, + save_dir: Optional[str] = None, + ) -> str: + """자막 파일 다운로드 → 로컬 저장. 저장 경로 반환.""" + target_dir = Path(save_dir) if save_dir else self.download_dir + target_dir.mkdir(parents=True, exist_ok=True) + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + } + + # Naver 리퍼러 헤더 + if sub.platform == "naver": + headers["Referer"] = "https://blog.naver.com/" + + async with httpx.AsyncClient( + timeout=60, follow_redirects=True, max_redirects=5 + ) as client: + resp = await client.get(sub.download_url, headers=headers) + resp.raise_for_status() + + # Content-Disposition에서 실제 파일명 추출 + cd = resp.headers.get("content-disposition", "") + if "filename" in cd: + m = re.search(r'filename[*]?=["\']?(?:UTF-8\'\')?([^"\';\n]+)', cd) + if m: + sub.filename = unquote(m.group(1).strip()) + + filepath = target_dir / sub.filename + filepath.write_bytes(resp.content) + + sub.local_path = str(filepath) + logger.info(f"자막 다운로드 완료: {filepath}") + return str(filepath) diff --git a/tools/title_matcher.py b/tools/title_matcher.py new file mode 100644 index 0000000..4921135 --- /dev/null +++ b/tools/title_matcher.py @@ -0,0 +1,212 @@ +"""제목 매칭 + NAS 폴더명 생성. + +Anissia(한글) ↔ Nyaa(영어/로마자) 제목을 매칭하고, +NAS 저장 폴더명을 [yy_q분기]제목 형식으로 생성합니다. +""" + +import re +import logging +import unicodedata +from difflib import SequenceMatcher +from typing import Optional + +logger = logging.getLogger("variet.tools.matcher") + + +# ────────────────────────────────────────────── +# 일어 → 로마자 변환 테이블 (히라가나/카타카나) +# ────────────────────────────────────────────── + +_KANA_ROMAJI = { + # 히라가나 + 'あ': 'a', 'い': 'i', 'う': 'u', 'え': 'e', 'お': 'o', + 'か': 'ka', 'き': 'ki', 'く': 'ku', 'け': 'ke', 'こ': 'ko', + 'さ': 'sa', 'し': 'shi', 'す': 'su', 'せ': 'se', 'そ': 'so', + 'た': 'ta', 'ち': 'chi', 'つ': 'tsu', 'て': 'te', 'と': 'to', + 'な': 'na', 'に': 'ni', 'ぬ': 'nu', 'ね': 'ne', 'の': 'no', + 'は': 'ha', 'ひ': 'hi', 'ふ': 'fu', 'へ': 'he', 'ほ': 'ho', + 'ま': 'ma', 'み': 'mi', 'む': 'mu', 'め': 'me', 'も': 'mo', + 'や': 'ya', 'ゆ': 'yu', 'よ': 'yo', + 'ら': 'ra', 'り': 'ri', 'る': 'ru', 'れ': 're', 'ろ': 'ro', + 'わ': 'wa', 'を': 'wo', 'ん': 'n', + 'が': 'ga', 'ぎ': 'gi', 'ぐ': 'gu', 'げ': 'ge', 'ご': 'go', + 'ざ': 'za', 'じ': 'ji', 'ず': 'zu', 'ぜ': 'ze', 'ぞ': 'zo', + 'だ': 'da', 'ぢ': 'di', 'づ': 'du', 'で': 'de', 'ど': 'do', + 'ば': 'ba', 'び': 'bi', 'ぶ': 'bu', 'べ': 'be', 'ぼ': 'bo', + 'ぱ': 'pa', 'ぴ': 'pi', 'ぷ': 'pu', 'ぺ': 'pe', 'ぽ': 'po', + 'きゃ': 'kya', 'きゅ': 'kyu', 'きょ': 'kyo', + 'しゃ': 'sha', 'しゅ': 'shu', 'しょ': 'sho', + 'ちゃ': 'cha', 'ちゅ': 'chu', 'ちょ': 'cho', + 'にゃ': 'nya', 'にゅ': 'nyu', 'にょ': 'nyo', + 'ひゃ': 'hya', 'ひゅ': 'hyu', 'ひょ': 'hyo', + 'みゃ': 'mya', 'みゅ': 'myu', 'みょ': 'myo', + 'りゃ': 'rya', 'りゅ': 'ryu', 'りょ': 'ryo', + 'ぎゃ': 'gya', 'ぎゅ': 'gyu', 'ぎょ': 'gyo', + 'じゃ': 'ja', 'じゅ': 'ju', 'じょ': 'jo', + 'びゃ': 'bya', 'びゅ': 'byu', 'びょ': 'byo', + 'ぴゃ': 'pya', 'ぴゅ': 'pyu', 'ぴょ': 'pyo', + 'っ': '', # 촉음 (다음 자음 반복) +} + +# 카타카나 → 히라가나 변환 오프셋 +_KATA_OFFSET = ord('ア') - ord('あ') + + +def _kata_to_hira(text: str) -> str: + """카타카나를 히라가나로 변환.""" + result = [] + for ch in text: + cp = ord(ch) + if 0x30A0 <= cp <= 0x30FF: # 카타카나 범위 + result.append(chr(cp - _KATA_OFFSET)) + else: + result.append(ch) + return "".join(result) + + +def japanese_to_romaji(text: str) -> str: + """일본어 텍스트를 로마자로 근사 변환.""" + text = _kata_to_hira(text) + + result = [] + i = 0 + while i < len(text): + # 2글자 매칭 우선 (きゃ 등) + if i + 1 < len(text) and text[i:i+2] in _KANA_ROMAJI: + result.append(_KANA_ROMAJI[text[i:i+2]]) + i += 2 + elif text[i] in _KANA_ROMAJI: + romaji = _KANA_ROMAJI[text[i]] + # 촉음(っ) 처리: 다음 자음 반복 + if text[i] == 'っ' and i + 1 < len(text): + next_romaji = _KANA_ROMAJI.get(text[i+1], "") + if next_romaji: + result.append(next_romaji[0]) + else: + result.append(romaji) + i += 1 + elif text[i] == 'ー': # 장음 + i += 1 + else: + # 한자, 영어, 숫자 등 → 그대로 + result.append(text[i]) + i += 1 + + return "".join(result) + + +def normalize_title(title: str) -> str: + """제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리.""" + title = title.lower().strip() + # 기수 표기 정규화: 2nd → 2, S2 → 2 + title = re.sub(r'\b(\d+)(?:st|nd|rd|th)\b', r'\1', title) + title = re.sub(r'\bs(\d+)\b', r'\1', title) + title = re.sub(r'season\s*(\d+)', r'\1', title) + title = re.sub(r'(\d+)\s*기', r'\1', title) + # 특수문자 제거 + title = re.sub(r'[^\w\s]', ' ', title) + title = re.sub(r'\s+', ' ', title).strip() + return title + + +def title_similarity(title_a: str, title_b: str) -> float: + """두 제목 간 유사도 (0.0 ~ 1.0).""" + a = normalize_title(title_a) + b = normalize_title(title_b) + return SequenceMatcher(None, a, b).ratio() + + +def match_titles( + korean_title: str, + original_title: str, + nyaa_results: list, + threshold: float = 0.4, +) -> list: + """Anissia 제목과 Nyaa 검색 결과 매칭. + + Args: + korean_title: 한글 제목 (Anissia subject) + original_title: 원어 제목 (Anissia originalSubject) + nyaa_results: TorrentResult 리스트 + threshold: 최소 유사도 + + Returns: + 매칭된 TorrentResult 리스트 (유사도 내림차순) + """ + # 원제의 로마자 변환 + romaji = japanese_to_romaji(original_title) + + scored = [] + for result in nyaa_results: + # Nyaa 제목에서 그룹태그 제거: [ASW] Title - 07 [...] → Title + clean_title = re.sub(r'\[[^\]]*\]', '', result.title).strip() + clean_title = re.sub(r'\s*-\s*\d+.*$', '', clean_title).strip() + + # 유사도 계산 (로마자 vs Nyaa 제목) + sim_romaji = title_similarity(romaji, clean_title) + # 한글 vs Nyaa (일부 자막 포함 릴리스인 경우) + sim_korean = title_similarity(korean_title, clean_title) + # 원제 그대로 vs Nyaa + sim_original = title_similarity(original_title, clean_title) + + best_sim = max(sim_romaji, sim_korean, sim_original) + + if best_sim >= threshold: + scored.append((best_sim, result)) + + # 유사도 내림차순 정렬 + scored.sort(key=lambda x: x[0], reverse=True) + return [r for _, r in scored] + + +# ────────────────────────────────────────────── +# NAS 폴더명 생성 +# ────────────────────────────────────────────── + +def get_quarter(date_str: str) -> tuple[int, int]: + """날짜 문자열에서 연도와 분기 추출. + + Args: + date_str: "2026-01-11" 형식 + + Returns: + (year, quarter): (26, 1) + """ + if not date_str: + from datetime import date + today = date.today() + year = today.year % 100 + quarter = (today.month - 1) // 3 + 1 + return year, quarter + + parts = date_str.split("-") + year = int(parts[0]) % 100 + month = int(parts[1]) + quarter = (month - 1) // 3 + 1 + return year, quarter + + +def make_nas_folder_name(title: str, start_date: str = "") -> str: + """NAS 저장 폴더명 생성. + + 예: [26_1분기]장송의프리렌2기 + """ + year, quarter = get_quarter(start_date) + + # 제목에서 폴더명에 쓸 수 없는 문자 제거 + safe_title = re.sub(r'[<>:"/\\|?*]', '', title) + safe_title = safe_title.strip() + + return f"[{year:02d}_{quarter}분기]{safe_title}" + + +def rename_subtitle_to_video( + video_filename: str, + subtitle_ext: str = ".ass", +) -> str: + """영상 파일명에 맞게 자막 파일명 생성. + + 예: [ASW] Sousou no Frieren S2 - 07.mkv → [ASW] Sousou no Frieren S2 - 07.ass + """ + stem = re.sub(r'\.[^.]+$', '', video_filename) + return f"{stem}{subtitle_ext}" diff --git a/workspaces.json b/workspaces.json index 7db0c9a..f10a682 100644 --- a/workspaces.json +++ b/workspaces.json @@ -1,8 +1,8 @@ { - "1479451607610691726": { - "name": "test_1", - "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1", - "channel_id": 1479451607610691726, + "5608566207": { + "name": "test_1_orphan_20260307", + "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260307", + "channel_id": 0, "git": { "url": "", "token": "", @@ -16,10 +16,27 @@ }, "docs_path": "docs/wiki" }, - "1479489442249969796": { - "name": "test_2", - "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2", - "channel_id": 1479489442249969796, + "8350378037": { + "name": "test_2_orphan_20260307", + "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2_orphan_20260307", + "channel_id": 0, + "git": { + "url": "", + "token": "", + "repo": "", + "branch": "main" + }, + "vikunja": { + "url": "", + "token": "", + "project_id": 0 + }, + "docs_path": "docs/wiki" + }, + "1479610776502403186": { + "name": "test_1", + "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1", + "channel_id": 1479610776502403186, "git": { "url": "", "token": "",