feat(tools): 애니메이션 자동화 파이프라인 구현
- 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: 세션 기록
This commit is contained in:
54
.agents/AGENT.md
Normal file
54
.agents/AGENT.md
Normal file
@@ -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 이스케이핑 이슈 방지)
|
||||||
163
.agents/GUIDE.md
Normal file
163
.agents/GUIDE.md
Normal file
@@ -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 |
|
||||||
35
.agents/references/architecture.md
Normal file
35
.agents/references/architecture.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
> 이 프로젝트의 아키텍처를 설명하는 문서입니다.
|
||||||
|
> AI 에이전트는 구현 전 이 문서를 반드시 확인합니다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
<!-- 프로젝트의 목적과 핵심 기능을 간략히 서술 -->
|
||||||
|
|
||||||
|
(프로젝트 설명을 여기에 작성하세요)
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
├── src/ # 소스 코드
|
||||||
|
├── tests/ # 테스트
|
||||||
|
├── docs/ # 문서
|
||||||
|
├── .agents/ # AI 에이전트 설정
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 핵심 모듈
|
||||||
|
|
||||||
|
<!-- 각 모듈의 역할과 의존 관계를 설명 -->
|
||||||
|
|
||||||
|
| 모듈 | 역할 | 의존성 |
|
||||||
|
|------|------|--------|
|
||||||
|
| (모듈명) | (역할 설명) | (의존하는 모듈) |
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
<!-- 주요 데이터 흐름을 Mermaid 다이어그램이나 텍스트로 설명 -->
|
||||||
|
|
||||||
|
(데이터 흐름을 여기에 작성하세요)
|
||||||
45
.agents/references/conventions.md
Normal file
45
.agents/references/conventions.md
Normal file
@@ -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>(<scope>): <description>
|
||||||
|
|
||||||
|
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]`
|
||||||
43
.agents/references/known-issues.md
Normal file
43
.agents/references/known-issues.md
Normal file
@@ -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` 접두어 사용 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프로젝트별 이슈
|
||||||
|
|
||||||
|
> 아래에 프로젝트 특화 이슈를 추가하세요.
|
||||||
|
|
||||||
|
(아직 기록된 프로젝트별 이슈가 없습니다)
|
||||||
37
.agents/references/tech-stack.md
Normal file
37
.agents/references/tech-stack.md
Normal file
@@ -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) |
|
||||||
40
.agents/workflows/check-gitea.md
Normal file
40
.agents/workflows/check-gitea.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
41
.agents/workflows/check-vikunja.md
Normal file
41
.agents/workflows/check-vikunja.md
Normal file
@@ -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 패턴을 사용합니다.
|
||||||
52
.agents/workflows/debug.md
Normal file
52
.agents/workflows/debug.md
Normal file
@@ -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
|
||||||
|
### [날짜] [키워드] — 한줄 요약
|
||||||
|
- **증상**: 무엇이 잘못되었는가
|
||||||
|
- **원인**: 근본 원인
|
||||||
|
- **해결**: 올바른 해결 방법
|
||||||
|
- **주의**: 재발 방지를 위한 교훈
|
||||||
|
```
|
||||||
165
.agents/workflows/end.md
Normal file
165
.agents/workflows/end.md
Normal file
@@ -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>(<scope>): <description>
|
||||||
|
|
||||||
|
type: feat|fix|refactor|test|docs|chore|ci|infra
|
||||||
|
scope: (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 최종 체크리스트
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 아래 항목 중 하나라도 누락되면 세션 종료를 완료할 수 없습니다.
|
||||||
|
|
||||||
|
- [ ] known-issues 업데이트됨 (새 이슈가 있었다면)
|
||||||
|
- [ ] devlog index 업데이트됨
|
||||||
|
- [ ] devlog entry 작성됨 (필요한 경우만)
|
||||||
|
- [ ] Vikunja 태스크 생성/완료 처리됨 (커밋 전수 검사 기반)
|
||||||
|
- [ ] Wiki 동기화됨 (아키텍처 변경이 있었다면)
|
||||||
|
- [ ] git push 완료
|
||||||
|
- [ ] 사용자에게 완료 보고
|
||||||
217
.agents/workflows/helpers/vikunja_helper.py
Normal file
217
.agents/workflows/helpers/vikunja_helper.py
Normal file
@@ -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()
|
||||||
100
.agents/workflows/helpers/wiki_helper.py
Normal file
100
.agents/workflows/helpers/wiki_helper.py
Normal file
@@ -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 <title> — 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>")
|
||||||
39
.agents/workflows/pre-task.md
Normal file
39
.agents/workflows/pre-task.md
Normal file
@@ -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개 이상인 경우)
|
||||||
|
- [ ] 작은 변경은 바로 실행하되, 변경 내용을 명확히 설명
|
||||||
65
.agents/workflows/start.md
Normal file
65
.agents/workflows/start.md
Normal file
@@ -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, 문서 정리
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# .agent/workflows/services.md 는 토큰이 포함되어 있으므로 제외
|
# 토큰 포함 파일 제외
|
||||||
.agent/workflows/services.md
|
.agent/workflows/services.md
|
||||||
|
.agents/workflows/services.md
|
||||||
sessions/
|
sessions/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
슬래시 커맨드로 워크스페이스 관리.
|
슬래시 커맨드로 워크스페이스 관리.
|
||||||
등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답).
|
등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답).
|
||||||
|
/task 커맨드로 프로젝트 선택 → 스레드 자동 생성 → 스레드 내 작업.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
@@ -58,6 +61,10 @@ ws_manager = WorkspaceManager()
|
|||||||
# 실행 중인 작업 추적 (채널ID → asyncio.Task)
|
# 실행 중인 작업 추적 (채널ID → asyncio.Task)
|
||||||
_running_tasks: dict[int, 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"
|
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회 호출: 분류 + 응답/계획)
|
# 통합 프롬프트 (1회 호출: 분류 + 응답/계획)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -160,10 +184,16 @@ async def on_ready():
|
|||||||
|
|
||||||
# 슬래시 커맨드 동기화 (길드별 = 즉시 반영)
|
# 슬래시 커맨드 동기화 (길드별 = 즉시 반영)
|
||||||
try:
|
try:
|
||||||
|
# 1) 글로벌 커맨드를 각 길드로 복사 + 동기화
|
||||||
for guild in bot.guilds:
|
for guild in bot.guilds:
|
||||||
bot.tree.copy_global_to(guild=guild)
|
bot.tree.copy_global_to(guild=guild)
|
||||||
synced = await bot.tree.sync(guild=guild)
|
synced = await bot.tree.sync(guild=guild)
|
||||||
logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})")
|
logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})")
|
||||||
|
|
||||||
|
# 2) 글로벌 커맨드 제거 (길드 커맨드와 중복 방지)
|
||||||
|
bot.tree.clear_commands(guild=None)
|
||||||
|
await bot.tree.sync()
|
||||||
|
logger.info("글로벌 슬래시 커맨드 정리 완료 (길드 전용)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"슬래시 커맨드 동기화 실패: {e}")
|
logger.error(f"슬래시 커맨드 동기화 실패: {e}")
|
||||||
|
|
||||||
@@ -236,11 +266,15 @@ async def on_message(message: discord.Message):
|
|||||||
await bot.process_commands(message)
|
await bot.process_commands(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 워크스페이스 채널인지 확인
|
# 워크스페이스 채널 또는 스레드 확인
|
||||||
if not ws_manager.is_workspace_channel(message.channel.id):
|
|
||||||
return
|
|
||||||
|
|
||||||
ws = ws_manager.get_workspace(message.channel.id)
|
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()
|
user_text = message.content.strip()
|
||||||
if not user_text:
|
if not user_text:
|
||||||
return
|
return
|
||||||
@@ -279,7 +313,11 @@ async def on_message(message: discord.Message):
|
|||||||
mode = result.get("mode", "chat")
|
mode = result.get("mode", "chat")
|
||||||
logger.info(f"통합 분류: {mode} - \"{user_text[:50]}\"")
|
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 미설정 안내 (차단하지 않음)
|
# Git/Vikunja 미설정 안내 (차단하지 않음)
|
||||||
if not ws.is_ready:
|
if not ws.is_ready:
|
||||||
missing = ws.missing_configs
|
missing = ws.missing_configs
|
||||||
@@ -330,6 +368,284 @@ async def on_message(message: discord.Message):
|
|||||||
await message.channel.send(embed=embed)
|
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)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# 설정 경고
|
# 설정 경고
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -444,6 +760,18 @@ async def _handle_task(message: discord.Message, text: str, ws):
|
|||||||
name=f"태스크 ({len(tasks)}개)", value=task_list[:1000], inline=False,
|
name=f"태스크 ({len(tasks)}개)", value=task_list[:1000], inline=False,
|
||||||
)
|
)
|
||||||
await message.channel.send(embed=plan_embed)
|
await message.channel.send(embed=plan_embed)
|
||||||
|
else:
|
||||||
|
# 태스크가 없지만 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:
|
else:
|
||||||
await message.channel.send(
|
await message.channel.send(
|
||||||
embed=discord.Embed(
|
embed=discord.Embed(
|
||||||
@@ -593,14 +921,29 @@ async def _handle_task(message: discord.Message, text: str, ws):
|
|||||||
|
|
||||||
except GeminiCallError as e:
|
except GeminiCallError as e:
|
||||||
await message.channel.send(
|
await message.channel.send(
|
||||||
embed=discord.Embed(title="❌ AI 호출 오류",
|
embed=discord.Embed(
|
||||||
description=f"```{str(e)[:500]}```", color=0xE74C3C)
|
title="❌ AI 호출 오류",
|
||||||
|
description=(
|
||||||
|
f"```{str(e)[:300]}```\n\n"
|
||||||
|
f"💡 **대응 방법:**\n"
|
||||||
|
f"• 요청을 더 짧게/구체적으로 다시 시도\n"
|
||||||
|
f"• 복잡한 요청은 단계별로 나눠서 요청\n"
|
||||||
|
f"• 잠시 후 다시 시도"
|
||||||
|
),
|
||||||
|
color=0xE74C3C,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"작업 오류: {e}", exc_info=True)
|
logger.error(f"작업 오류: {e}", exc_info=True)
|
||||||
await message.channel.send(
|
await message.channel.send(
|
||||||
embed=discord.Embed(title="❌ 오류",
|
embed=discord.Embed(
|
||||||
description=f"```{str(e)[:500]}```", color=0xE74C3C)
|
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)
|
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="봇 응답 테스트")
|
@bot.command(name="ping", help="봇 응답 테스트")
|
||||||
async def ping_command(ctx: commands.Context):
|
async def ping_command(ctx: commands.Context):
|
||||||
latency = round(bot.latency * 1000)
|
latency = round(bot.latency * 1000)
|
||||||
|
|||||||
10
config.py
10
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: str = os.getenv(
|
||||||
"WORKSPACE_BASE_DIR", r"c:\Users\Certes\Desktop\VW_Proj"
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
return result
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
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:
|
except FileNotFoundError:
|
||||||
raise GeminiCallError("gemini CLI를 찾을 수 없습니다.")
|
raise GeminiCallError("gemini CLI를 찾을 수 없습니다.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -162,7 +163,7 @@ class GeminiCaller:
|
|||||||
|
|
||||||
async def call_agent(
|
async def call_agent(
|
||||||
self, role: str, context: str, cwd: str,
|
self, role: str, context: str, cwd: str,
|
||||||
timeout: int = 300,
|
timeout: int = 600,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""에이전트 모드 — 프로젝트 디렉토리에서 실행.
|
"""에이전트 모드 — 프로젝트 디렉토리에서 실행.
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ class GeminiCaller:
|
|||||||
role: 프롬프트 역할 (coder)
|
role: 프롬프트 역할 (coder)
|
||||||
context: 작업 지시 (태스크 설명)
|
context: 작업 지시 (태스크 설명)
|
||||||
cwd: 프로젝트 루트 경로 (여기서 Gemini 실행)
|
cwd: 프로젝트 루트 경로 (여기서 Gemini 실행)
|
||||||
timeout: 타임아웃 (에이전트는 더 길게 — 기본 5분)
|
timeout: 타임아웃 (에이전트는 더 길게 — 기본 10분)
|
||||||
"""
|
"""
|
||||||
async with _semaphore:
|
async with _semaphore:
|
||||||
return await self._call_agent_impl(role, context, cwd, timeout)
|
return await self._call_agent_impl(role, context, cwd, timeout)
|
||||||
@@ -197,7 +198,8 @@ class GeminiCaller:
|
|||||||
f"=== IMPORTANT ===\n"
|
f"=== IMPORTANT ===\n"
|
||||||
f"프로젝트 루트: {cwd}\n"
|
f"프로젝트 루트: {cwd}\n"
|
||||||
f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n"
|
f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n"
|
||||||
f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요."
|
f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요.\n"
|
||||||
|
f"모든 응답, 주석, 문서는 반드시 한국어로 작성하세요."
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -225,8 +227,9 @@ class GeminiCaller:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Gemini [{role}] agent 타임아웃 ({timeout}s) — cwd={cwd}")
|
||||||
raise GeminiCallError(
|
raise GeminiCallError(
|
||||||
f"Gemini agent timeout ({timeout}s) -- role={role}, cwd={cwd}"
|
f"Gemini 에이전트 응답 시간 초과 ({timeout}초). 작업이 너무 복잡할 수 있습니다."
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise GeminiCallError("gemini CLI를 찾을 수 없습니다.")
|
raise GeminiCallError("gemini CLI를 찾을 수 없습니다.")
|
||||||
|
|||||||
5
docs/devlog/2026-03-08.md
Normal file
5
docs/devlog/2026-03-08.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-03-08 Devlog
|
||||||
|
|
||||||
|
| # | 시간 | 작업 | 커밋 | 상태 |
|
||||||
|
|---|------|------|------|------|
|
||||||
|
| 1 | 15:19~16:02 | 애니메이션 자동화 파이프라인 구현 (Anissia/Nyaa/qBit/자막다운로더/NAS스캐너 + AI 평문 통합) | `pending` | ✅ |
|
||||||
27
docs/devlog/entries/20260308-001.md
Normal file
27
docs/devlog/entries/20260308-001.md
Normal file
@@ -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 핸들러 + 슬래시 커맨드
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
- 실제 디스코드 평문 테스트 (봇은 실행되어 있으나 사용자가 아직 테스트하지 않음)
|
||||||
|
- 자막 다운로드 후 영상 파일명에 맞게 자동 리네이밍 기능은 코드 준비되었으나 파이프라인에서 아직 호출하지 않음
|
||||||
@@ -1,42 +1,39 @@
|
|||||||
You are a **Coder** — 프로젝트에서 직접 코드를 구현하는 AI 에이전트입니다.
|
You are a **Coder** — 프로젝트에서 파일을 직접 생성/수정하는 AI 에이전트입니다.
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
|
||||||
|
**핵심: 태스크의 description만 보고, 완성된 결과물을 파일로 만드세요.**
|
||||||
|
|
||||||
|
소스코드뿐 아니라 **문서(.md), 설정 파일, 워크플로우, 데이터 파일** 등 모든 유형의 파일을 다룹니다.
|
||||||
|
|
||||||
## 작업 흐름
|
## 작업 흐름
|
||||||
|
|
||||||
### 1단계: 구현
|
### 1단계: 탐색
|
||||||
- 현재 디렉토리의 프로젝트 파일을 확인
|
- 프로젝트 구조를 먼저 파악하세요 (디렉토리 확인, 관련 파일 검색)
|
||||||
- 필요한 변경사항을 구현
|
- 기존 프로젝트라면 **관련 파일을 찾아서 읽은 뒤** 수정하세요
|
||||||
- 파일을 직접 생성/수정하여 저장
|
- 빈 프로젝트라면 필요한 파일을 처음부터 만드세요
|
||||||
|
|
||||||
### 2단계: 자가 검증 (반드시 수행)
|
### 2단계: 구현
|
||||||
구현 후 다음을 직접 확인하세요:
|
- 파일을 직접 생성/수정하여 저장하세요
|
||||||
- 생성/수정한 파일을 다시 읽어서 내용이 완전한지 확인
|
- 코드블록으로 출력하지 말고, **파일을 직접 만드세요**
|
||||||
- 파일 간 참조(import, src 경로 등)가 올바른지 확인
|
|
||||||
- 문법 오류가 없는지 확인
|
|
||||||
- 핵심 기능이 빠진 것은 없는지 확인
|
|
||||||
|
|
||||||
### 3단계: 자가 수정
|
### 3단계: 자가 검증 (반드시 수행)
|
||||||
검증에서 문제를 발견하면:
|
구현 후 직접 확인하세요:
|
||||||
- 직접 수정하세요
|
- 생성/수정한 파일을 다시 읽어서 내용이 완전한지
|
||||||
- 다시 2단계로 돌아가 확인하세요
|
- 파일 간 참조(import, 경로 등)가 올바른지
|
||||||
- 문제가 없을 때까지 반복하세요
|
- 핵심 내용이 빠진 것은 없는지
|
||||||
|
|
||||||
### 4단계: 완료 보고
|
### 4단계: 자가 수정
|
||||||
모든 검증을 통과한 후에만 완료 보고하세요:
|
검증에서 문제를 발견하면 직접 수정 → 다시 3단계 → 문제 없을 때까지 반복.
|
||||||
|
|
||||||
|
### 5단계: 완료 보고
|
||||||
- 변경한 파일 목록
|
- 변경한 파일 목록
|
||||||
- 각 파일의 핵심 내용 한 줄 설명
|
- 각 파일의 핵심 내용 한 줄 설명
|
||||||
- 자가 검증에서 발견하고 수정한 것이 있으면 언급
|
- 자가 검증에서 발견하고 수정한 것이 있으면 언급
|
||||||
|
- **실행/사용 방법이 있으면 반드시 안내** (예: 서버 시작 명령, 테스트 방법, 설치 절차 등)
|
||||||
|
|
||||||
## 규칙
|
## 규칙
|
||||||
|
|
||||||
### 파일 작성
|
- 동작하는 완성된 결과물을 만드세요. 뼈대나 TODO를 남기지 마세요.
|
||||||
- 코드블록으로 출력하지 말고, **파일을 직접 생성/수정**하세요
|
- 기존 프로젝트의 스타일과 구조를 유지하세요.
|
||||||
- 새 프로젝트(빈 폴더)인 경우, 필요한 파일을 모두 처음부터 만드세요
|
- 코드 주석과 문서는 **한국어**로 작성. 코드 식별자는 영어 유지.
|
||||||
- 기존 프로젝트인 경우, 기존 구조와 스타일을 유지하세요
|
|
||||||
|
|
||||||
### 완성도
|
|
||||||
- 동작하는 완성된 코드를 작성하세요. 뼈대나 TODO를 남기지 마세요
|
|
||||||
- 모든 파일은 실행 가능한 상태여야 합니다
|
|
||||||
|
|
||||||
### 언어
|
|
||||||
- 코드 주석(comment)과 문서(docstring, README 등)는 **한국어**로 작성
|
|
||||||
- 변수명, 함수명 등 코드 식별자는 영어 유지
|
|
||||||
|
|||||||
@@ -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개 이상으로 분할하기
|
- 단순한 요청을 3개 이상으로 분할하기
|
||||||
|
- 작업할 게 없는데 억지로 태스크 만들기
|
||||||
|
|
||||||
## 이전 시도 피드백이 있는 경우
|
## 이전 시도 피드백이 있는 경우
|
||||||
|
|
||||||
@@ -35,7 +30,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
|
|||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
### 직접 처리한 경우:
|
### 직접 처리:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"summary": "처리 결과 요약",
|
"summary": "처리 결과 요약",
|
||||||
@@ -44,7 +39,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 태스크 분배가 필요한 경우:
|
### 태스크 분배:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"summary": "작업 요약",
|
"summary": "작업 요약",
|
||||||
@@ -53,7 +48,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"title": "태스크 제목",
|
"title": "태스크 제목",
|
||||||
"description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다.",
|
"description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다. 대상 파일, 내용, 형식을 구체적으로 포함하세요.",
|
||||||
"type": "create|modify|delete"
|
"type": "create|modify|delete"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -63,6 +58,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- description에 모든 구현 세부사항을 적으세요
|
- description에 **모든 구현 세부사항**을 적으세요. 코더는 이것만 봅니다.
|
||||||
- 한국어로 작성하세요
|
- 한국어로 작성하세요.
|
||||||
- 단순한 일을 복잡하게 만들지 마세요
|
- 단순한 일을 복잡하게 만들지 마세요.
|
||||||
|
- 이전 대화 맥락이 주어지면, 그 내용을 반영하세요.
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
You are a **Reviewer** — 에이전트가 작성한 코드를 리뷰합니다.
|
You are a **Reviewer** — 에이전트가 수행한 작업의 결과물을 리뷰합니다.
|
||||||
|
|
||||||
## 입력
|
## 리뷰 원칙
|
||||||
|
|
||||||
- 요청된 태스크 목록
|
**핵심 질문: "사용자의 요청이 충족되었는가?"**
|
||||||
- 에이전트의 작업 보고
|
|
||||||
- 실제 생성/수정된 파일 내용
|
|
||||||
|
|
||||||
## 리뷰 기준
|
작업 유형에 따라 리뷰 기준을 자율적으로 적용하세요:
|
||||||
|
- **코드** → 실행 가능 여부, 핵심 기능 구현, 파일 간 참조 정합성
|
||||||
|
- **문서** → 내용 완성도, 구조, 요청된 범위 충족
|
||||||
|
- **설정/워크플로우** → 형식 유효성, 필수 항목 포함
|
||||||
|
|
||||||
|
## 통과/반려 기준
|
||||||
|
|
||||||
### passed: true (통과)
|
### passed: true (통과)
|
||||||
- 태스크 요구사항을 충족하는 파일이 존재함
|
- 요청된 결과물이 존재하고, 핵심 내용이 포함됨
|
||||||
- 코드가 실행 가능한 상태임 (문법 오류 없음)
|
- 명백한 결함이 없음
|
||||||
- 핵심 기능이 구현되어 있음
|
|
||||||
|
|
||||||
### passed: false (반려)
|
### passed: false (반려)
|
||||||
- 비어있거나 잘린 파일이 존재함
|
- 비어있거나 잘린 파일
|
||||||
- 핵심 기능이 빠져 있음
|
- 핵심 기능/내용이 빠져 있음
|
||||||
- 명백한 버그가 있음 (런타임 에러 확실)
|
- 명백한 버그나 구조적 오류
|
||||||
- 파일 간 참조가 깨져 있음 (예: import 경로 오류)
|
- 파일 간 참조가 깨져 있음
|
||||||
|
|
||||||
### 반려하지 마세요
|
### 반려하지 마세요
|
||||||
- 스타일이나 코드 품질 문제 (개선 제안으로 남기세요)
|
- 스타일이나 품질 문제 (개선 제안으로 남기세요)
|
||||||
- "더 좋을 수 있는" 부분
|
- "더 좋을 수 있는" 부분
|
||||||
- 사소한 미비점
|
- 사소한 미비점
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ You are a **Reviewer** — 에이전트가 작성한 코드를 리뷰합니다.
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- 한국어로 작성하세요
|
- **결과물의 유형에 맞는 기준**으로 리뷰하세요. 모든 작업을 코드 기준으로 보지 마세요.
|
||||||
- **기능 동작 여부**에 집중하세요. 완벽함을 요구하지 마세요.
|
- critical 이슈가 있을 때만 passed=false.
|
||||||
- critical 이슈가 있을 때만 passed=false
|
- 의심이 되면 통과시키세요.
|
||||||
- 의심이 되면 통과시키세요
|
- 한국어로 작성하세요.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Summarizer
|
# Summarizer
|
||||||
|
|
||||||
당신은 AI Agent Team의 **총평 작성자**입니다.
|
당신은 AI Agent Team의 **총평 작성자**입니다.
|
||||||
작업 파이프라인이 완료된 후, 전체 결과를 사용자가 이해하기 쉽게 요약합니다.
|
작업 파이프라인 완료 후, 사용자가 이해하기 쉽게 결과를 요약합니다.
|
||||||
|
|
||||||
## 입력
|
## 입력
|
||||||
|
|
||||||
- 사용자의 원래 요청
|
- 사용자의 원래 요청
|
||||||
- 태스크 수
|
- 태스크 수
|
||||||
- 에이전트 코딩 결과 보고
|
- 에이전트 작업 보고
|
||||||
- 리뷰 결과
|
- 리뷰 결과
|
||||||
|
|
||||||
## 출력 형식 (JSON)
|
## 출력 형식 (JSON)
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
{
|
{
|
||||||
"title": "작업 완료 한줄 제목",
|
"title": "작업 완료 한줄 제목",
|
||||||
"changes": [
|
"changes": [
|
||||||
{"file": "path/to/file.py", "description": "변경 내용 설명"}
|
{"file": "path/to/file", "description": "변경 내용 설명"}
|
||||||
],
|
],
|
||||||
"warnings": ["주의사항이 있으면 여기에"],
|
"warnings": ["주의사항이 있으면 여기에"],
|
||||||
"next_steps": ["사용자가 다음에 할 수 있는 작업 제안"],
|
"next_steps": ["사용자가 다음에 할 수 있는 작업 제안"],
|
||||||
@@ -26,8 +26,10 @@
|
|||||||
|
|
||||||
## 규칙
|
## 규칙
|
||||||
|
|
||||||
- 기술 용어는 최소화, 사용자 관점에서 서술
|
- 사용자 관점에서 서술하세요. 기술 용어는 최소화.
|
||||||
- 한국어로 답변
|
- 한국어로 답변.
|
||||||
- 주의사항이 없으면 warnings를 빈 배열로
|
- 주의사항이 없으면 warnings를 빈 배열로.
|
||||||
- next_steps는 1-2개만 제안
|
- next_steps는 1-2개만 구체적으로 제안.
|
||||||
- changes의 file은 에이전트 보고에서 언급된 파일명을 사용
|
- **실행 가능한 결과물이 있으면 next_steps 첫 번째에 실행 방법을 반드시 포함** (예: "터미널에서 `npm start` 실행", "http://localhost:3000 접속" 등).
|
||||||
|
- changes의 file은 에이전트 보고에서 언급된 파일명 사용.
|
||||||
|
- 코드 작업과 문서 작업 모두 동일한 형식으로 요약하세요.
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
# Unified Agent — 분류 + 즉답 통합 프롬프트
|
# Unified Agent — 분류 + 즉답
|
||||||
|
|
||||||
당신은 **Variet Agent**입니다. 사용자의 메시지를 받아 판단하여 즉답하거나 작업으로 넘깁니다.
|
당신은 **Variet Agent**입니다. 사용자의 메시지를 판단하여 즉답하거나 작업으로 넘깁니다.
|
||||||
|
|
||||||
## 판단 기준
|
## 판단 원칙
|
||||||
|
|
||||||
1. **즉답 가능** (질문, 인사, 설명 요청, 의견 교환)
|
**핵심 질문: "이 요청이 무엇을 원하는가?"**
|
||||||
→ `mode: "chat"` — 바로 답변을 포함하세요.
|
|
||||||
|
|
||||||
2. **작업 필요** (코드 수정, 파일 생성, 리팩토링, 배포 등 실제 변경이 필요한 요청)
|
- **애니메이션 관련 요청** → `mode: "anime"` (자막, 영상, 다운로드, 편성표 등)
|
||||||
→ `mode: "task"` — 작업 요약만 작성하세요. 태스크 분할은 하지 마세요.
|
- **프로젝트 파일 변경이 필요** → `mode: "task"`
|
||||||
|
- **대화로 해결 가능** → `mode: "chat"`
|
||||||
|
- **판단 불가** → `mode: "clarify"`
|
||||||
|
|
||||||
3. **불명확** (맥락 부족, 대상 불분명)
|
### anime 판단 기준
|
||||||
→ `mode: "clarify"` — 되물을 질문을 포함하세요.
|
다음 키워드/의도가 포함되면 `anime`로 분류:
|
||||||
|
- 애니메이션/애니 자막 다운로드, 영상 다운로드
|
||||||
|
- 편성표 확인, 이번 분기 애니, 신작
|
||||||
|
- NAS에 저장, 토렌트, nyaa, 자막 수집
|
||||||
|
- 특정 애니 제목 언급 + 다운/검색/모아줘 등
|
||||||
|
|
||||||
|
### 추가 원칙
|
||||||
|
- **확신이 없으면 chat**으로 대응하세요.
|
||||||
|
- "분석해줘", "제안해줘" 등은 **대부분 대화**입니다.
|
||||||
|
- "만들어줘", "수정해줘" 등은 문맥을 보세요. 파일/코드 변경이면 task.
|
||||||
|
- 에러/버그 수정 요청 → **task**.
|
||||||
|
|
||||||
## 출력 형식 (반드시 JSON)
|
## 출력 형식 (반드시 JSON)
|
||||||
|
|
||||||
### 즉답인 경우:
|
### chat:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"response": "여기에 답변 내용"
|
"response": "마크다운 형식의 완성된 답변"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 작업인 경우:
|
### task:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mode": "task",
|
"mode": "task",
|
||||||
@@ -31,18 +42,46 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 불명확한 경우:
|
### clarify:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mode": "clarify",
|
"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 외의 텍스트를 포함하지 마세요.
|
- 반드시 위 JSON 형식만 출력하세요. JSON 외의 텍스트를 포함하지 마세요.
|
||||||
- chat 모드의 response는 마크다운 사용 가능, 완성된 답변이어야 합니다.
|
- chat의 response는 마크다운 사용 가능, **완성된 답변**이어야 합니다.
|
||||||
- task 모드에서는 summary만 작성하세요. tasks 배열을 만들지 마세요.
|
- task에서는 summary만 작성하세요. tasks 배열을 만들지 마세요.
|
||||||
|
- anime에서는 사용자 의도를 정확히 파악하여 action과 파라미터를 설정하세요.
|
||||||
- 한국어로 응답하세요.
|
- 한국어로 응답하세요.
|
||||||
- 이전 대화 기록이 주어지면, 맥락을 고려하세요.
|
- 이전 대화 기록이 주어지면, 맥락을 고려하세요.
|
||||||
|
|
||||||
|
|||||||
14
run_bot.bat
Normal file
14
run_bot.bat
Normal file
@@ -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
|
||||||
175
tests/test_anime_tools.py
Normal file
175
tests/test_anime_tools.py
Normal file
@@ -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())
|
||||||
1
tools/__init__.py
Normal file
1
tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Anime automation tools package.
|
||||||
244
tools/anime_pipeline.py
Normal file
244
tools/anime_pipeline.py
Normal file
@@ -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 []
|
||||||
120
tools/anissia_client.py
Normal file
120
tools/anissia_client.py
Normal file
@@ -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()
|
||||||
|
]
|
||||||
152
tools/nas_scanner.py
Normal file
152
tools/nas_scanner.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
156
tools/nyaa_client.py
Normal file
156
tools/nyaa_client.py
Normal file
@@ -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
|
||||||
198
tools/qbit_client.py
Normal file
198
tools/qbit_client.py
Normal file
@@ -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}
|
||||||
260
tools/subtitle_downloader.py
Normal file
260
tools/subtitle_downloader.py
Normal file
@@ -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)
|
||||||
212
tools/title_matcher.py
Normal file
212
tools/title_matcher.py
Normal file
@@ -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}"
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"1479451607610691726": {
|
"5608566207": {
|
||||||
"name": "test_1",
|
"name": "test_1_orphan_20260307",
|
||||||
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1",
|
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260307",
|
||||||
"channel_id": 1479451607610691726,
|
"channel_id": 0,
|
||||||
"git": {
|
"git": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": "",
|
"token": "",
|
||||||
@@ -16,10 +16,27 @@
|
|||||||
},
|
},
|
||||||
"docs_path": "docs/wiki"
|
"docs_path": "docs/wiki"
|
||||||
},
|
},
|
||||||
"1479489442249969796": {
|
"8350378037": {
|
||||||
"name": "test_2",
|
"name": "test_2_orphan_20260307",
|
||||||
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2",
|
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2_orphan_20260307",
|
||||||
"channel_id": 1479489442249969796,
|
"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": {
|
"git": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": "",
|
"token": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user