Merge branch 'main' of https://github.com/g1nations/2nd
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Connect AI auto-generated
|
||||||
|
.DS_Store
|
||||||
|
.obsidian/
|
||||||
|
.trash/
|
||||||
|
node_modules/
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
.cache/
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"offset":619328157,"ts":1778162777308}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"pid":19848,"heartbeat":1778168497853}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 자동 생성 — Connect AI 1인 기업 모드
|
||||||
|
# 시크릿·API 키 보호
|
||||||
|
_agents/*/config.md
|
||||||
|
# 도구 설정 JSON 안에 API 키·텔레그램 봇 토큰이 들어갈 수 있어 git에서 제외
|
||||||
|
_agents/*/tools/*.json
|
||||||
|
_agents/*/tools/youtube_account.json
|
||||||
|
|
||||||
|
# 외부 API 응답 캐시 (재현 가능)
|
||||||
|
_cache/
|
||||||
|
|
||||||
|
# 대용량 임시 산출물
|
||||||
|
_tmp/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 📊 Business 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 수익화 모델 1개 가설 검증 → 매출화
|
||||||
|
- 핵심 KPI 대시보드 운영
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 가격·번들 옵션 2~3안 비교 메모
|
||||||
|
- 경쟁사 3곳 ROI 분석
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 결정 가능한 권고 (A/B 중 어느 쪽인지) + 근거 숫자
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 💰 Business (Head of Business) 개인 메모리
|
||||||
|
|
||||||
|
_Business 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 💰 Business 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Business 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 💰 Business — 도구 매니페스트
|
||||||
|
|
||||||
|
_Business 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `revenue_pull`
|
||||||
|
Stripe/Toss/PayPal 매출 데이터
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `analytics_pull`
|
||||||
|
Google Analytics / Plausible 트래픽
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `pnl_generator`
|
||||||
|
월별 P&L 마크다운 자동 생성
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/business/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 🧭 CEO (Chief Executive Agent) 개인 메모리
|
||||||
|
|
||||||
|
_CEO 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 🧭 CEO 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 CEO 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 🧭 CEO — 도구 매니페스트
|
||||||
|
|
||||||
|
_CEO 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `approval_gate`
|
||||||
|
위험 액션(deploy/post/send/rm) 사용자 승인 게이트
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `team_briefing`
|
||||||
|
주간 전체 회의 자동 진행 + 회의록 정리
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `router`
|
||||||
|
사용자 명령 → 적합한 specialist로 분배
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/ceo/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 🎨 Designer 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 브랜드 컬러·타이포·로고 시스템 확정
|
||||||
|
- 썸네일/포스트 템플릿 3종 표준화
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 디자인 브리프 1건 작성 (레퍼런스 5장 포함)
|
||||||
|
- 썸네일 컨셉 3안 비교 정리
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 텍스트 설명만 X — 색상 코드·폰트명·레이아웃 좌표까지 구체적으로
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 🎨 Designer (Lead Designer) 개인 메모리
|
||||||
|
|
||||||
|
_Designer 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 🎨 Designer 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Designer 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 🎨 Designer — 도구 매니페스트
|
||||||
|
|
||||||
|
_Designer 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `image_local`
|
||||||
|
로컬 SDXL/FLUX 이미지 생성 (오프라인 정체성)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `image_cloud`
|
||||||
|
DALL-E/Replicate (Connected 모드 토글)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `brand_check`
|
||||||
|
브랜드 색상 팔레트·타이포 일관성 검증
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `asset_library`
|
||||||
|
_company/assets/ 자동 정리·태깅
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/designer/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 💻 Developer 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 반복 업무 자동화 스크립트 5개 운영
|
||||||
|
- 데이터 파이프라인 / API 연결 안정화
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 가장 시간 잡아먹는 수동 작업 1개 자동화
|
||||||
|
- 기존 스크립트 1개 리팩터·테스트 보강
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 항상 실행 가능한 코드 + 사용법 1줄
|
||||||
|
- 외부 호출은 키 노출 없이 환경변수로
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 💻 Developer (Lead Engineer) 개인 메모리
|
||||||
|
|
||||||
|
_Developer 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 💻 Developer 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Developer 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 💻 Developer — 도구 매니페스트
|
||||||
|
|
||||||
|
_Developer 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `project_scaffolder`
|
||||||
|
_company/projects/<name>/ 폴더 자동 생성 (vite/next/astro)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `dev_server`
|
||||||
|
자체 dev server + 포트 매니저 + 라이브 미리보기 푸시
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `git_committer`
|
||||||
|
작업 단위 자동 커밋
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `deploy_cli`
|
||||||
|
Vercel/Netlify/Cloudflare 배포 (deploy --prod는 항상 승인)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `lint_test`
|
||||||
|
테스트·린트·타입체크 자동 실행
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/developer/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ✂️ Editor 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 영상 편집 디렉션 템플릿 (오프닝·B-roll·아웃트로) 표준화
|
||||||
|
- 평균 컷 리듬·자막 톤 가이드 확립
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 최근 영상 1편 컷·자막·B-roll 디렉션 작성
|
||||||
|
- 스크립트 1편 다듬기 (불필요 문장 제거)
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 막연한 "다듬어줘" X — 시간 코드 + 구체 액션
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ✂️ Editor (Video & Content Editor) 개인 메모리
|
||||||
|
|
||||||
|
_Editor 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ✂️ Editor 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Editor 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ✂️ Editor — 도구 매니페스트
|
||||||
|
|
||||||
|
_Editor 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `ffmpeg_runner`
|
||||||
|
컷·자막·B-roll 합성
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `whisper_local`
|
||||||
|
로컬 자막 생성 (오프라인)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `reframe_9_16`
|
||||||
|
16:9 → 9:16 자동 리프레임 (릴스/숏츠)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/editor/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 📸 Instagram 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 피드 톤앤매너 확립 + 팔로워 5천 도달
|
||||||
|
- 릴스 평균 도달 1만 이상
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 릴스 기획 3개 (훅·보이스오버·자막 포함)
|
||||||
|
- 캡션·해시태그 패턴 정리
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 매 산출물마다 게시 시간 + 후속 스토리 아이디어 1개
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 📷 Instagram (Head of Instagram) 개인 메모리
|
||||||
|
|
||||||
|
_Instagram 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 📷 Instagram 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Instagram 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 📷 Instagram — 도구 매니페스트
|
||||||
|
|
||||||
|
_Instagram 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `instagram_account`
|
||||||
|
Meta Graph API OAuth (비즈니스 계정)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `feed_poster`
|
||||||
|
피드/스토리/릴스 게시 (Draft → 승인 → 게시)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `dm_responder`
|
||||||
|
DM·댓글 분류 + 답글 초안
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `insights_pull`
|
||||||
|
도달·참여·팔로워 추이
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/instagram/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 🔍 Researcher 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 산업·경쟁사 트렌드 리포트 월 1회 발행
|
||||||
|
- 인용 가능한 1차 자료 라이브러리 구축
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 우리 분야 트렌드 5개 짧은 메모
|
||||||
|
- 경쟁사 2곳 최근 활동·성공 콘텐츠 정리
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 출처 링크 필수, 의견과 사실 분리해서 표기
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 🔍 Researcher (Trend & Data Researcher) 개인 메모리
|
||||||
|
|
||||||
|
_Researcher 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 🔍 Researcher 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Researcher 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 🔍 Researcher — 도구 매니페스트
|
||||||
|
|
||||||
|
_Researcher 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `web_search`
|
||||||
|
Brave/DuckDuckGo 검색 (Connected)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `page_fetcher`
|
||||||
|
본문 추출 + 출처 인용
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `monitor_daily`
|
||||||
|
매일 내 분야 뉴스 → CEO 브리핑
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/researcher/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 🗂️ Secretary 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 데일리 브리핑·할 일 정리 루틴 자동화
|
||||||
|
- 다른 에이전트 산출물을 한 줄 요약으로 모아서 보고
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 매일 09:00 데일리 브리핑 정리
|
||||||
|
- 미해결 할 일 5건 추적 + 다음 액션 명시
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- "정리"보다 "다음 액션 1개" 명시가 우선
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 📱 Secretary (Personal Assistant) 개인 메모리
|
||||||
|
|
||||||
|
_Secretary 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 📱 Secretary 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Secretary 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 📱 영숙 — 도구 매니페스트
|
||||||
|
|
||||||
|
_영숙 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `calendar_local`
|
||||||
|
_agents/secretary/calendar.md (Lv.1 오프라인)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `calendar_caldav`
|
||||||
|
CalDAV (iCloud/Google 호환, Connected 토글)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `telegram_bot`
|
||||||
|
텔레그램 양방향 봇 (이미 활성)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `kakao_alert`
|
||||||
|
카카오톡 "나에게 보내기" 단방향 알림
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `email_triage`
|
||||||
|
IMAP/Gmail 분류 + 답장 초안
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/secretary/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 📅 Google Calendar
|
||||||
|
|
||||||
|
비서가 본인의 Google Calendar와 양방향 연결됩니다 — 다가오는 일정 자동 동기화 + 마감일(due) 있는 추적 작업을 자동으로 캘린더에 등록 (5분 전·1시간 전 알림 자동).
|
||||||
|
|
||||||
|
## 무엇을 추가로 하나요? (vs iCal 읽기 전용)
|
||||||
|
- ✍️ **자동 일정 생성** — 추적기에 due 들어가면 즉시 캘린더에 일정 만듦
|
||||||
|
- 🔁 일정 수정·삭제도 가능 (작업 완료/취소 시 캘린더 정리)
|
||||||
|
- 🔔 알림 자동 셋팅 (5분 전, 1시간 전 팝업)
|
||||||
|
- 📥 동시에 읽기도 가능 (별도 iCal 셋업 불필요)
|
||||||
|
|
||||||
|
## 셋업 (한 번만, 5~10분)
|
||||||
|
|
||||||
|
명령 팔레트 → **`Connect AI: Google Calendar 자동 일정 연결 📅`** 실행하면 마법사가 안내합니다:
|
||||||
|
|
||||||
|
1. Google Cloud Console에서 OAuth 클라이언트 만들기 (가이드 따라 클릭)
|
||||||
|
2. Client ID + Secret 붙여넣기
|
||||||
|
3. 브라우저로 로그인 → 끝
|
||||||
|
|
||||||
|
## 동작 방식
|
||||||
|
- 사용자: *"내일까지 광고주 자료 정리해야 해"* 라고 텔레그램으로 시킴
|
||||||
|
- 비서: 추적기 등록 + 자동으로 `내일 09:00` Google Calendar에 일정 생성
|
||||||
|
- 알림: 5분 전, 1시간 전 자동 팝업
|
||||||
|
|
||||||
|
## 설정 (⚙️에서 조정 가능)
|
||||||
|
- `CALENDAR_ID` — 기본 `primary` (본인 메인 캘린더). 다른 캘린더 ID 가능
|
||||||
|
- `DEFAULT_DURATION_MINUTES` — 기본 60분. 작업 일정 길이가 명시 안 됐을 때 사용
|
||||||
|
|
||||||
|
## ▶ 실행하면?
|
||||||
|
현재 연결 상태와 설정값을 진단 출력합니다 (이벤트 생성 X). 진짜 일정 등록은 추적 작업이 들어올 때 자동.
|
||||||
|
|
||||||
|
## 보안
|
||||||
|
- Client ID/Secret/Refresh Token은 `google_calendar_write.json` 한 파일에. `.gitignore` 처리되어 git에 안 올라갑니다
|
||||||
|
- 권한 범위: `calendar.events`만 (캘린더 일정 읽기/쓰기). 메일·드라이브·연락처 다 못 봅니다
|
||||||
|
- 연결 해제: 명령 팔레트에서 같은 명령 → "연결 해제" 선택. 또는 [myaccount.google.com/permissions](https://myaccount.google.com/permissions)에서 직접 권한 회수
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Google Calendar 자동 일정 등록 — secretary_calendar_write_v1.
|
||||||
|
|
||||||
|
이 스크립트는 OAuth와 실제 이벤트 생성을 직접 하지 않습니다 — VS Code
|
||||||
|
호스트(extension.ts)에서 직접 처리해요. 이 도구의 역할은:
|
||||||
|
1) 설정 상태를 확인해서 사용자에게 알려주기 (▶ 클릭 시)
|
||||||
|
2) ⚙️ 폼에서 CALENDAR_ID / DEFAULT_DURATION_MINUTES 같은 보조 설정 노출
|
||||||
|
|
||||||
|
연결 자체는 명령 팔레트에서:
|
||||||
|
Cmd+Shift+P → 'Connect AI: Google Calendar 자동 일정 연결 📅'
|
||||||
|
"""
|
||||||
|
import os, json, sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CONFIG = os.path.join(HERE, "google_calendar_write.json")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(CONFIG):
|
||||||
|
print("⚠️ 아직 설정이 없어요.")
|
||||||
|
print(" 명령 팔레트(Cmd+Shift+P) → 'Connect AI: Google Calendar 자동 일정 연결' 실행")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
with open(CONFIG, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 설정 파일 파싱 실패: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
cid = (cfg.get("CLIENT_ID") or "").strip()
|
||||||
|
cs = (cfg.get("CLIENT_SECRET") or "").strip()
|
||||||
|
rt = (cfg.get("REFRESH_TOKEN") or "").strip()
|
||||||
|
cal = (cfg.get("CALENDAR_ID") or "primary").strip()
|
||||||
|
dur = int(cfg.get("DEFAULT_DURATION_MINUTES") or 60)
|
||||||
|
who = (cfg.get("_CONNECTED_AS") or "").strip()
|
||||||
|
when = (cfg.get("_CONNECTED_AT") or "").strip()
|
||||||
|
print("─── Google Calendar 자동 일정 등록 상태 ───")
|
||||||
|
print(f" Client ID : {'설정됨 (' + cid[:8] + '…)' if cid else '(없음)'}")
|
||||||
|
print(f" Client Secret : {'설정됨' if cs else '(없음)'}")
|
||||||
|
print(f" Refresh Token : {'유효 ✓' if rt else '(없음)'}")
|
||||||
|
print(f" Calendar ID : {cal}")
|
||||||
|
print(f" 기본 일정 길이 : {dur}분")
|
||||||
|
if who:
|
||||||
|
print(f" 연결 계정 : {who}")
|
||||||
|
if when:
|
||||||
|
print(f" 연결 시각 : {when[:19]}")
|
||||||
|
if not (cid and cs and rt):
|
||||||
|
print()
|
||||||
|
print("⚠️ 셋업이 완료되지 않았어요.")
|
||||||
|
print(" 명령 팔레트(Cmd+Shift+P) → 'Connect AI: Google Calendar 자동 일정 연결'")
|
||||||
|
sys.exit(1)
|
||||||
|
print()
|
||||||
|
print("✅ 연결 정상. 마감일(due) 있는 추적 작업이 등록되면 자동으로 캘린더에 일정이 생성됩니다.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 📨 텔레그램 연결
|
||||||
|
|
||||||
|
비서(Secretary)가 텔레그램 메신저로 보고를 보내려면 봇 토큰과 chat_id가 필요해요. **⚙️ 버튼을 누르고 폼에 입력**하면 끝 — config.md를 열 필요 없습니다.
|
||||||
|
|
||||||
|
## 어떻게 도와주나요?
|
||||||
|
- ⚙️ 폼에 입력 → `telegram_setup.json`에 저장 (`.gitignore`로 git에서 제외)
|
||||||
|
- ▶ 실행 → 텔레그램에 연결 테스트 메시지 1발 발송
|
||||||
|
- 모든 에이전트(YouTube 도구 포함)가 이 설정을 자동으로 공유
|
||||||
|
|
||||||
|
## 봇 만드는 법 (한 번만, 약 2분)
|
||||||
|
1. 텔레그램에서 [@BotFather](https://t.me/BotFather) 검색 → `/newbot` 입력
|
||||||
|
2. 봇 이름·핸들 정하면 `123456789:ABC...` 형식 토큰을 줍니다 → ⚙️의 `TELEGRAM_BOT_TOKEN`에 입력
|
||||||
|
3. 새로 만든 봇한테 `/start` 같은 메시지 1번 보내기 (chat_id 활성화)
|
||||||
|
4. 브라우저에서 `https://api.telegram.org/bot<토큰>/getUpdates` 열어 `chat.id` 숫자 복사
|
||||||
|
5. ⚙️의 `TELEGRAM_CHAT_ID`에 입력 → 저장
|
||||||
|
6. ▶ 실행 → 텔레그램에서 "✅ 비서 연결 정상" 메시지 도착하면 끝
|
||||||
|
|
||||||
|
## 이 설정을 누가 사용하나?
|
||||||
|
- 비서 자체 (데일리 브리핑·할 일 알림 등)
|
||||||
|
- YouTube 도구 (내 영상 체크·경쟁 채널 분석 보고서 푸시)
|
||||||
|
- 향후 추가될 모든 에이전트의 텔레그램 알림
|
||||||
|
|
||||||
|
## 안전
|
||||||
|
- 토큰은 `.gitignore` 처리되어 GitHub에 안 올라갑니다
|
||||||
|
- 폼은 토큰 칸을 자동으로 password 형식으로 가립니다 (다른 사람 화면 공유해도 노출 X)
|
||||||
|
- 토큰 노출됐다 싶으면 [@BotFather](https://t.me/BotFather) → `/revoke`로 즉시 폐기 가능
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Telegram 연결 — secretary_telegram_v2.
|
||||||
|
|
||||||
|
Secretary 에이전트의 텔레그램 연결 도구. 토큰·chat_id를 Skills의 ⚙️ 폼에
|
||||||
|
입력하면 `telegram_setup.json`에 저장되고, 이 스크립트가 메시지 1발 보내서
|
||||||
|
연결을 테스트합니다. 회사의 모든 에이전트(YouTube 포함)가 이 설정을
|
||||||
|
공유합니다."""
|
||||||
|
import os, json, sys, time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CONFIG = os.path.join(HERE, "telegram_setup.json")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(CONFIG):
|
||||||
|
print("❌ telegram_setup.json이 없어요. 먼저 ⚙️ 클릭해서 토큰을 입력해주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
with open(CONFIG, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 설정 파일 파싱 실패: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
token = (cfg.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
chat = (cfg.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
if not token or not chat:
|
||||||
|
print("❌ TELEGRAM_BOT_TOKEN 또는 TELEGRAM_CHAT_ID가 비어있어요.")
|
||||||
|
print(" 봇 만들기: Telegram → @BotFather → /newbot → 토큰 받기")
|
||||||
|
print(" chat_id : 봇에 메시지 1번 → https://api.telegram.org/bot<TOKEN>/getUpdates 에서 chat.id")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pip install requests")
|
||||||
|
sys.exit(1)
|
||||||
|
body = f"✅ 비서(Secretary) 텔레그램 연결 정상 — {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n이 메시지가 보이면 모든 에이전트가 이 채널로 보고를 보낼 수 있어요."
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
json={"chat_id": chat, "text": body, "parse_mode": "Markdown"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(f"✅ 전송 OK — 텔레그램에서 확인하세요. ({len(body)}자)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 전송 실패: {e}")
|
||||||
|
if "Bad Request" in str(e):
|
||||||
|
print(" chat_id가 정확한지, 봇과 한 번이라도 대화를 시작했는지 확인하세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ✍️ Writer 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 후크·CTA 라이브러리 50개 운영
|
||||||
|
- 채널·인스타·블로그 톤앤매너 가이드 확정
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 영상 스크립트 초안 2편 (후크 3안 포함)
|
||||||
|
- 인스타 캡션 5개 + 블로그 글 1편
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 한 산출물에 후크/본문/CTA를 명확히 분리
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ✍️ Writer (Copywriter) 개인 메모리
|
||||||
|
|
||||||
|
_Writer 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ✍️ Writer 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 Writer 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ✍️ Writer — 도구 매니페스트
|
||||||
|
|
||||||
|
_Writer 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `tone_learner`
|
||||||
|
사용자 과거 글 학습 → 톤 복제
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `multi_platform_adapt`
|
||||||
|
하나의 스크립트 → YouTube/IG/블로그 자동 변환
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `hook_library`
|
||||||
|
후크·CTA 라이브러리 운영
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/writer/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 🎯 YouTube 에이전트 — 나의 미션
|
||||||
|
|
||||||
|
> 🌞 24시간 업무가 켜져 있으면 이 미션을 향해 자동으로 한 스텝씩 일합니다.
|
||||||
|
> 자유롭게 수정하세요. 비워두면 회사 공동 목표만 따라갑니다.
|
||||||
|
|
||||||
|
## 장기 목표 (3~6개월)
|
||||||
|
- 채널 정체성 확립 + 구독자 1만 도달
|
||||||
|
- 영상 평균 시청 지속률 50% 이상
|
||||||
|
|
||||||
|
## 이번 주 목표
|
||||||
|
- 후크 강한 영상 기획서 3개 작성
|
||||||
|
- 감시 채널 댓글 패턴에서 후크 단어 5개 추출
|
||||||
|
- 경쟁 채널 인기 영상 → 다음 액션 브리프 1건
|
||||||
|
|
||||||
|
## 사용 가능한 도구 (Skills)
|
||||||
|
- 🔑 `youtube_account` — API 키·내 채널·감시 채널·텔레그램 한 번에 설정
|
||||||
|
- 🎯 `trend_sniper` — 키워드 기반 떡상 영상 패턴 분석
|
||||||
|
- 🌙 `auto_planner` — 트렌드 스나이퍼 무인 반복 실행
|
||||||
|
- 🎬 `my_videos_check` — 내 채널 영상이 잘 올라갔는지 자동 판단
|
||||||
|
- 💬 `comment_harvester` — 감시 채널 댓글 → memory.md 누적
|
||||||
|
- 🔭 `competitor_brief` — 경쟁 채널 → 지시문 형식 다음 액션
|
||||||
|
- 📨 `telegram_notify` — 다른 도구 보고를 메신저로 자동 푸시
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 추상적 조언 대신 **실행 가능한 산출물** (제목·썸네일 브리프·스크립트 후크)
|
||||||
|
- 매번 다음 단계 1줄을 명시
|
||||||
|
- 메모리(`memory.md`)에 누적된 댓글·반응 키워드를 후크에 반영
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 📺 YouTube (Head of YouTube) 개인 메모리
|
||||||
|
|
||||||
|
_YouTube 에이전트만 읽고 쓰는 개인 노트. 학습·교훈·자주 쓰는 패턴이 누적됩니다._
|
||||||
|
|
||||||
|
## 학습 기록
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 📺 YouTube 페르소나 디테일
|
||||||
|
|
||||||
|
_여기에 YouTube 에이전트에게 주고 싶은 추가 지시·말투·취향·예시 등을 자유롭게 적으세요._
|
||||||
|
_매 호출 시 시스템 프롬프트에 자동 주입됩니다. (git에 동기화됨)_
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self-rag
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 📺 레오 — 도구 매니페스트
|
||||||
|
|
||||||
|
_레오 에이전트가 어떤 도구를 어디까지 자율적으로 쓸 수 있는지 정의합니다._
|
||||||
|
_매번 시스템 프롬프트로 주입되며, 텔레그램에서 `/tools`로 현재 상태 확인 가능._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자율도 레벨
|
||||||
|
|
||||||
|
AUTONOMY_LEVEL: 2
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off — 도구 전체 비활성 (이 에이전트는 채팅만) |
|
||||||
|
| 1 | Read-only — 읽기·분석·보고만, 외부에 쓰기 X |
|
||||||
|
| 2 | Draft — 초안 작성 후 사용자 승인 게이트 통과해야 실행 ⭐ 권장 기본값 |
|
||||||
|
| 3 | Auto — 화이트리스트 안에서 사용자 승인 없이 실행 |
|
||||||
|
|
||||||
|
> 위 `AUTONOMY_LEVEL` 줄의 숫자(0~3)를 직접 바꾸면 다음 호출부터 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 가능한 도구
|
||||||
|
|
||||||
|
### `youtube_account`
|
||||||
|
YouTube Data API v3 OAuth 연결
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `comment_replier`
|
||||||
|
댓글 분류 + 답글 초안 (Draft 레벨에서 동작)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `video_uploader`
|
||||||
|
제목·태그·썸네일·예약발행 업로드
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `analytics_pull`
|
||||||
|
주간 인사이트 (조회수·시청 지속률·구독 전환)
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
### `trend_sniper`
|
||||||
|
내 분야 트렌드 → Writer에게 아이디어 전달
|
||||||
|
|
||||||
|
- `enabled`: true
|
||||||
|
- `requires_credentials`: `config.md` 참조
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 안전 규칙 (모든 레벨 공통, 절대 우회 X)
|
||||||
|
|
||||||
|
- **삭제·배포·발송**(rm, deploy --prod, send, publish) 류는 자율도와 무관하게 **항상 승인 게이트**.
|
||||||
|
- 외부 API 호출 전 `config.md`의 토큰 존재 여부 확인.
|
||||||
|
- 모든 외부 행동은 `_agents/youtube/activity.log`에 한 줄 기록 (감사용).
|
||||||
|
- 승인 대기 액션은 `approvals/pending/` 에 저장 → 텔레그램 `/approvals` 로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_레벨을 어떻게 골라야 할지 모르겠다면 `2 (Draft)`가 안전한 시작점입니다._
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 🌙 오토 플래너
|
||||||
|
|
||||||
|
트렌드 스나이퍼를 정해진 간격으로 반복 실행해서 패턴 데이터를 쌓아주는 무인 작업자예요. 한 번 트렌드를 보면 지금 잘 되는 영상 한 장만 보이지만, 8시간 동안 2시간마다 4번 보면 "어떤 키워드의 후크가 시간이 지나도 계속 살아남는지"가 보이기 시작합니다 — 자는 동안에 그 작업을 대신해줍니다.
|
||||||
|
|
||||||
|
## 어떻게 도와주나요?
|
||||||
|
- ⏰ N시간마다 `trend_sniper.py`를 자동 실행 (스나이퍼 결과는 매번 sessions/에 누적)
|
||||||
|
- 🛌 잘 때 켜두면 아침에 4~5번분의 트렌드 스냅샷이 쌓여 있어요
|
||||||
|
- 📊 같은 키워드라도 시간대별로 어떤 영상이 새로 떠오르는지 비교 가능
|
||||||
|
|
||||||
|
## 어떤 상황에 켜면 좋나요?
|
||||||
|
- 새 채널 컨셉을 결정하기 전, 며칠치 트렌드를 누적해서 보고 싶을 때
|
||||||
|
- 회사 일/외출 중 백그라운드에서 데이터만 모아두고 싶을 때
|
||||||
|
- 특정 키워드의 알고리즘 반응이 시간대마다 다른지 확인하고 싶을 때
|
||||||
|
|
||||||
|
## 시작하기 전 체크
|
||||||
|
- 트렌드 스나이퍼 도구가 먼저 설정돼 있어야 해요 (YouTube API 키, 키워드 목록 등)
|
||||||
|
- 첫 실행 전에 트렌드 스나이퍼를 한 번 수동으로 돌려서 정상 작동 확인을 권장합니다
|
||||||
|
|
||||||
|
## 설정값 (auto_planner.json)
|
||||||
|
- `INTERVAL_HOURS` — 몇 시간마다 실행할지 (기본 2)
|
||||||
|
- `TOTAL_RUN_HOURS` — 총 가동 시간 (기본 8 → 8시간 동안 4회 실행)
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
패널의 [▶ 실행]을 누르면 시작됩니다. 또는 터미널에서:
|
||||||
|
```bash
|
||||||
|
python auto_planner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 이 스크립트는 끝날 때까지 터미널을 점유해요. 백그라운드로 돌리려면 별도 창에서 실행하세요. 중단하려면 Ctrl+C.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Auto Planner — runs trend_sniper.py on a fixed interval for a chosen
|
||||||
|
duration (e.g. overnight). Reads its config from auto_planner.json."""
|
||||||
|
import os, json, time, datetime, subprocess, sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CONFIG_PATH = os.path.join(HERE, "auto_planner.json")
|
||||||
|
SNIPER_PATH = os.path.join(HERE, "trend_sniper.py")
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 설정 파일을 읽을 수 없어요: {CONFIG_PATH}\n{e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = load_config()
|
||||||
|
interval_h = float(cfg.get("INTERVAL_HOURS", 2))
|
||||||
|
total_h = float(cfg.get("TOTAL_RUN_HOURS", 8))
|
||||||
|
print(f"\n🚀 [오토 플래너] {total_h}시간 동안 {interval_h}시간마다 트렌드 분석 실행")
|
||||||
|
if not os.path.exists(SNIPER_PATH):
|
||||||
|
print(f"❌ trend_sniper.py를 찾을 수 없어요: {SNIPER_PATH}")
|
||||||
|
sys.exit(1)
|
||||||
|
start = time.time()
|
||||||
|
loop = 0
|
||||||
|
while True:
|
||||||
|
if time.time() - start > total_h * 3600:
|
||||||
|
print("\n☀️ 목표 가동 시간을 채웠어요. 종료합니다.")
|
||||||
|
break
|
||||||
|
loop += 1
|
||||||
|
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
print(f"\n[{ts}] 🤖 {loop}회차 트렌드 스나이핑")
|
||||||
|
try:
|
||||||
|
subprocess.run([sys.executable, SNIPER_PATH], check=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 실행 실패: {e}")
|
||||||
|
print(f"⏳ 다음 실행: {interval_h}시간 후")
|
||||||
|
time.sleep(interval_h * 3600)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 📈 채널 완전 분석
|
||||||
|
|
||||||
|
본인 YouTube 채널을 한 번에 깊이있게 진단합니다. 추가 입력 없이 외부 연결 패널의 API 키 + 채널 ID만 있으면 즉시 작동.
|
||||||
|
|
||||||
|
## 무엇을 분석하나요?
|
||||||
|
- **채널 개요** — 구독자·총 조회수·영상 수·평균 조회수
|
||||||
|
- **업로드 패턴** — 최근 30일 업로드 횟수·요일·영상 길이
|
||||||
|
- **성과 통계** — 중간값/평균 조회수, 평균 참여율
|
||||||
|
- **떡상 vs 부진 비교** — 인기 영상과 부진 영상의 제목·길이 패턴 차이
|
||||||
|
- **자동 추천** — 데이터 기반 다음 액션 (LLM 호출 없이 통계만으로)
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
`youtube_account.json`의 `YOUTUBE_API_KEY` + `MY_CHANNEL_HANDLE` 또는 `MY_CHANNEL_ID` (외부 연결 패널에서 1회 입력하면 끝)
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
- 콘솔에 8개 섹션 보고서
|
||||||
|
- `channel_full_analysis_report.md`에 누적 저장
|
||||||
|
- (선택) 텔레그램 자동 알림
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Channel Full Analysis — comprehensive overview of your YouTube channel.
|
||||||
|
|
||||||
|
Input: just YOUTUBE_API_KEY + MY_CHANNEL_ID/HANDLE from youtube_account.json.
|
||||||
|
No additional config needed. Output: full report with stats, patterns, and
|
||||||
|
data-driven recommendations.
|
||||||
|
"""
|
||||||
|
import os, json, sys, time, datetime, statistics, re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ACCOUNT = os.path.join(HERE, "youtube_account.json")
|
||||||
|
REPORT = os.path.join(HERE, "channel_full_analysis_report.md")
|
||||||
|
|
||||||
|
def _load(p):
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def _resolve_channel_id(youtube, handle, channel_id):
|
||||||
|
if channel_id:
|
||||||
|
return channel_id
|
||||||
|
if not handle:
|
||||||
|
return None
|
||||||
|
h = handle.lstrip("@")
|
||||||
|
try:
|
||||||
|
r = youtube.search().list(part="snippet", q=h, type="channel", maxResults=1).execute()
|
||||||
|
items = r.get("items", [])
|
||||||
|
if items:
|
||||||
|
return items[0]["snippet"]["channelId"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 채널 ID 조회 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_iso_duration(d):
|
||||||
|
"""ISO 8601 duration (PT4M30S) → seconds."""
|
||||||
|
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", d or "")
|
||||||
|
if not m: return 0
|
||||||
|
h, mi, s = m.groups()
|
||||||
|
return int(h or 0) * 3600 + int(mi or 0) * 60 + int(s or 0)
|
||||||
|
|
||||||
|
def _fmt_duration(sec):
|
||||||
|
if sec < 60: return f"{sec}s"
|
||||||
|
if sec < 3600: return f"{sec//60}m {sec%60}s"
|
||||||
|
return f"{sec//3600}h {(sec%3600)//60}m"
|
||||||
|
|
||||||
|
def _resolve_telegram(account):
|
||||||
|
"""Same fallback chain as my_videos_check.py."""
|
||||||
|
import json as _json
|
||||||
|
token = (account.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
chat = (account.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
if token and chat:
|
||||||
|
return token, chat
|
||||||
|
brain_root = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
||||||
|
sec_json = os.path.join(brain_root, "_agents", "secretary", "tools", "telegram_setup.json")
|
||||||
|
if (not token or not chat) and os.path.exists(sec_json):
|
||||||
|
try:
|
||||||
|
with open(sec_json, "r", encoding="utf-8") as f:
|
||||||
|
cfg = _json.load(f)
|
||||||
|
if not token: token = (cfg.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
if not chat: chat = (cfg.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return token, chat
|
||||||
|
|
||||||
|
def _push_telegram(account, text):
|
||||||
|
token, chat = _resolve_telegram(account)
|
||||||
|
if not token or not chat:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
requests.post(
|
||||||
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
json={"chat_id": chat, "text": text, "parse_mode": "Markdown"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
print("📨 텔레그램으로 보고 전송")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 텔레그램 전송 실패: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(ACCOUNT):
|
||||||
|
print("❌ youtube_account.json이 없어요. 외부 연결 패널에서 YouTube API 키와 채널 ID 입력해주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
acct = _load(ACCOUNT)
|
||||||
|
api_key = (acct.get("YOUTUBE_API_KEY") or "").strip()
|
||||||
|
handle = (acct.get("MY_CHANNEL_HANDLE") or "").strip()
|
||||||
|
chan_id = (acct.get("MY_CHANNEL_ID") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
print("❌ YOUTUBE_API_KEY가 비어있어요. 외부 연결 패널 → YouTube Data API 카드에 입력해주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
if not (handle or chan_id):
|
||||||
|
print("❌ MY_CHANNEL_HANDLE 또는 MY_CHANNEL_ID 필요. 외부 연결 패널 → 채널 ID 입력해주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
except ImportError:
|
||||||
|
print("❌ google-api-python-client 미설치.")
|
||||||
|
print(" 터미널에서 한 줄: pip3 install google-api-python-client requests")
|
||||||
|
sys.exit(1)
|
||||||
|
youtube = build("youtube", "v3", developerKey=api_key)
|
||||||
|
|
||||||
|
cid = _resolve_channel_id(youtube, handle, chan_id)
|
||||||
|
if not cid:
|
||||||
|
print("❌ 채널 ID를 찾지 못했어요. 외부 연결 패널의 채널 ID 확인.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"📈 [채널 완전 분석] 채널 {handle or cid} 분석 중...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. 채널 메타
|
||||||
|
ch = youtube.channels().list(part="snippet,statistics,brandingSettings", id=cid).execute()
|
||||||
|
if not ch.get("items"):
|
||||||
|
print("❌ 채널 데이터를 가져오지 못했어요. API 키·할당량 확인.")
|
||||||
|
sys.exit(1)
|
||||||
|
c = ch["items"][0]
|
||||||
|
sn = c.get("snippet", {})
|
||||||
|
st = c.get("statistics", {})
|
||||||
|
title = sn.get("title", "(이름 없음)")
|
||||||
|
subs = int(st.get("subscriberCount", 0))
|
||||||
|
total_views = int(st.get("viewCount", 0))
|
||||||
|
video_count = int(st.get("videoCount", 0))
|
||||||
|
pub_at = sn.get("publishedAt", "")[:10]
|
||||||
|
|
||||||
|
print("─── 1. 채널 개요 ───")
|
||||||
|
print(f" 채널: {title}")
|
||||||
|
print(f" 핸들: {sn.get('customUrl', handle or '(없음)')}")
|
||||||
|
print(f" 구독자: {subs:,}명")
|
||||||
|
print(f" 총 조회수: {total_views:,}회")
|
||||||
|
print(f" 업로드 영상: {video_count}개")
|
||||||
|
print(f" 채널 가입: {pub_at}")
|
||||||
|
avg_per_video = total_views // max(1, video_count)
|
||||||
|
print(f" 영상당 평균 조회: {avg_per_video:,}회")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 2. 최근 30일 영상 분석 (uploads playlist 사용 — search보다 quota 절약)
|
||||||
|
uploads = c.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads") if "contentDetails" in c else None
|
||||||
|
if not uploads:
|
||||||
|
# contentDetails 없으면 search로 폴백
|
||||||
|
cd = youtube.channels().list(part="contentDetails", id=cid).execute()
|
||||||
|
if cd.get("items"):
|
||||||
|
uploads = cd["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)
|
||||||
|
recent_video_ids = []
|
||||||
|
if uploads:
|
||||||
|
next_token = None
|
||||||
|
while len(recent_video_ids) < 50:
|
||||||
|
args = {"part": "snippet,contentDetails", "playlistId": uploads, "maxResults": 50}
|
||||||
|
if next_token: args["pageToken"] = next_token
|
||||||
|
pi = youtube.playlistItems().list(**args).execute()
|
||||||
|
for item in pi.get("items", []):
|
||||||
|
pub = item["snippet"]["publishedAt"]
|
||||||
|
pub_dt = datetime.datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
||||||
|
if pub_dt < cutoff:
|
||||||
|
break
|
||||||
|
recent_video_ids.append(item["contentDetails"]["videoId"])
|
||||||
|
next_token = pi.get("nextPageToken")
|
||||||
|
if not next_token: break
|
||||||
|
if recent_video_ids and datetime.datetime.fromisoformat(pi["items"][-1]["snippet"]["publishedAt"].replace("Z", "+00:00")) < cutoff:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not recent_video_ids:
|
||||||
|
print("⚠️ 최근 30일 동안 업로드한 영상이 없어요. 영상 업로드 후 다시 분석해주세요.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 3. 영상별 통계 (50개씩 나눠서)
|
||||||
|
all_vids = []
|
||||||
|
for i in range(0, len(recent_video_ids), 50):
|
||||||
|
chunk = recent_video_ids[i:i+50]
|
||||||
|
st_resp = youtube.videos().list(part="snippet,statistics,contentDetails", id=",".join(chunk)).execute()
|
||||||
|
for v in st_resp.get("items", []):
|
||||||
|
stats = v.get("statistics", {})
|
||||||
|
sn_v = v.get("snippet", {})
|
||||||
|
cd_v = v.get("contentDetails", {})
|
||||||
|
views = int(stats.get("viewCount", 0))
|
||||||
|
likes = int(stats.get("likeCount", 0))
|
||||||
|
comments = int(stats.get("commentCount", 0))
|
||||||
|
duration_sec = _parse_iso_duration(cd_v.get("duration", ""))
|
||||||
|
pub = sn_v.get("publishedAt", "")
|
||||||
|
pub_dt = datetime.datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
||||||
|
all_vids.append({
|
||||||
|
"id": v["id"],
|
||||||
|
"title": sn_v.get("title", ""),
|
||||||
|
"views": views,
|
||||||
|
"likes": likes,
|
||||||
|
"comments": comments,
|
||||||
|
"duration_sec": duration_sec,
|
||||||
|
"pub_dt": pub_dt,
|
||||||
|
"engagement_rate": (likes + comments) / views if views > 0 else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
all_vids.sort(key=lambda x: x["views"], reverse=True)
|
||||||
|
views_list = [v["views"] for v in all_vids]
|
||||||
|
median_views = statistics.median(views_list) if views_list else 0
|
||||||
|
mean_views = statistics.mean(views_list) if views_list else 0
|
||||||
|
|
||||||
|
print("─── 2. 최근 30일 업로드 패턴 ───")
|
||||||
|
print(f" 업로드 횟수: {len(all_vids)}개 (월평균 {len(all_vids):.1f}개)")
|
||||||
|
weekday_counts = Counter(v["pub_dt"].strftime("%A") for v in all_vids)
|
||||||
|
weekday_kr = {"Monday":"월","Tuesday":"화","Wednesday":"수","Thursday":"목","Friday":"금","Saturday":"토","Sunday":"일"}
|
||||||
|
top_day = weekday_counts.most_common(1)
|
||||||
|
if top_day:
|
||||||
|
print(f" 주로 업로드한 요일: {weekday_kr.get(top_day[0][0], top_day[0][0])}요일 ({top_day[0][1]}회)")
|
||||||
|
avg_duration = sum(v["duration_sec"] for v in all_vids) / len(all_vids)
|
||||||
|
print(f" 평균 영상 길이: {_fmt_duration(int(avg_duration))}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("─── 3. 성과 통계 ───")
|
||||||
|
print(f" 중간값 조회수: {int(median_views):,}회")
|
||||||
|
print(f" 평균 조회수: {int(mean_views):,}회")
|
||||||
|
avg_eng = sum(v["engagement_rate"] for v in all_vids) / len(all_vids) * 100 if all_vids else 0
|
||||||
|
print(f" 평균 참여율 (좋아요+댓글)/조회: {avg_eng:.2f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 떡상 / 부진 분류
|
||||||
|
hot = [v for v in all_vids if v["views"] >= median_views * 1.5]
|
||||||
|
cold = [v for v in all_vids if v["views"] < median_views * 0.5]
|
||||||
|
|
||||||
|
print("─── 4. 🔥 떡상 영상 (중간값 × 1.5 이상) ───")
|
||||||
|
if not hot:
|
||||||
|
print(" (없음 — 모든 영상이 평균 근처)")
|
||||||
|
else:
|
||||||
|
for v in hot[:5]:
|
||||||
|
print(f" 🔥 {v['views']:>8,}회 · 참여 {v['engagement_rate']*100:.2f}% · {_fmt_duration(v['duration_sec'])} · {v['title'][:50]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("─── 5. 🥶 부진 영상 (중간값 × 0.5 미만) ───")
|
||||||
|
if not cold:
|
||||||
|
print(" (없음 — 모든 영상이 평균 근처)")
|
||||||
|
else:
|
||||||
|
for v in cold[:5]:
|
||||||
|
print(f" 🥶 {v['views']:>8,}회 · 참여 {v['engagement_rate']*100:.2f}% · {_fmt_duration(v['duration_sec'])} · {v['title'][:50]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 6. 패턴 비교 — 떡상 vs 부진의 차이
|
||||||
|
print("─── 6. 떡상 vs 부진 — 패턴 비교 ───")
|
||||||
|
if hot and cold:
|
||||||
|
hot_avg_dur = sum(v["duration_sec"] for v in hot) / len(hot)
|
||||||
|
cold_avg_dur = sum(v["duration_sec"] for v in cold) / len(cold)
|
||||||
|
hot_avg_title = sum(len(v["title"]) for v in hot) / len(hot)
|
||||||
|
cold_avg_title = sum(len(v["title"]) for v in cold) / len(cold)
|
||||||
|
print(f" 떡상 영상 평균 길이: {_fmt_duration(int(hot_avg_dur))}")
|
||||||
|
print(f" 부진 영상 평균 길이: {_fmt_duration(int(cold_avg_dur))}")
|
||||||
|
if abs(hot_avg_dur - cold_avg_dur) > 60:
|
||||||
|
longer = "떡상" if hot_avg_dur > cold_avg_dur else "부진"
|
||||||
|
print(f" → {longer} 영상이 평균 {abs(int(hot_avg_dur - cold_avg_dur))}초 더 길어요")
|
||||||
|
print(f" 떡상 영상 평균 제목 길이: {hot_avg_title:.0f}자")
|
||||||
|
print(f" 부진 영상 평균 제목 길이: {cold_avg_title:.0f}자")
|
||||||
|
else:
|
||||||
|
print(" (떡상 또는 부진 데이터 부족 — 영상이 더 쌓이면 다시 분석)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 7. 자동 추천 (LLM 없이 데이터만으로)
|
||||||
|
print("─── 7. 🧭 다음 액션 추천 (데이터 기반) ───")
|
||||||
|
actions = []
|
||||||
|
if hot:
|
||||||
|
actions.append(f"🔥 떡상한 {len(hot)}개 영상의 제목·후크 패턴을 다음 영상에 적용 — 가장 잘 된 후크는 \"{hot[0]['title'][:50]}\"")
|
||||||
|
if cold:
|
||||||
|
actions.append(f"🥶 부진한 {len(cold)}개는 썸네일 A/B 테스트 또는 제목 리네이밍 후보")
|
||||||
|
if avg_eng < 2.0:
|
||||||
|
actions.append(f"💗 평균 참여율 {avg_eng:.2f}% — 영상 끝에 명확한 CTA(좋아요·구독) 추가 추천 (보통 3% 이상이 건강함)")
|
||||||
|
elif avg_eng > 5.0:
|
||||||
|
actions.append(f"💗 참여율 {avg_eng:.2f}% — 매우 좋음. 시청자와 강한 연결 구축됨, 상품·멤버십 도입 고려 시점")
|
||||||
|
if len(all_vids) < 4:
|
||||||
|
actions.append("📅 월 4개 미만 업로드 — 알고리즘 노출 위해 최소 주 1회 권장")
|
||||||
|
elif len(all_vids) > 12:
|
||||||
|
actions.append("📅 월 12개 이상 업로드 — 양은 충분, 영상별 품질·후크에 집중 추천")
|
||||||
|
if not actions:
|
||||||
|
actions.append("✅ 채널 상태 안정적 — 현재 패턴 유지하며 시청자 댓글에서 다음 콘텐츠 아이디어 수집")
|
||||||
|
for a in actions:
|
||||||
|
print(f" • {a}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 8. 보고서 .md 저장
|
||||||
|
summary_lines = [
|
||||||
|
f"# 📈 채널 완전 분석 — {time.strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
f"채널: **{title}** · 구독자 **{subs:,}** · 영상 **{video_count}**개",
|
||||||
|
"",
|
||||||
|
"## 최근 30일 통계",
|
||||||
|
f"- 업로드: {len(all_vids)}개",
|
||||||
|
f"- 조회수 중간값: **{int(median_views):,}**",
|
||||||
|
f"- 평균 참여율: **{avg_eng:.2f}%**",
|
||||||
|
f"- 평균 영상 길이: **{_fmt_duration(int(avg_duration))}**",
|
||||||
|
"",
|
||||||
|
f"## 🔥 떡상 영상 ({len(hot)}개)",
|
||||||
|
]
|
||||||
|
for v in hot[:5]:
|
||||||
|
summary_lines.append(f"- {v['views']:,}회 · {v['title']}")
|
||||||
|
summary_lines.append(f"\n## 🥶 부진 영상 ({len(cold)}개)")
|
||||||
|
for v in cold[:5]:
|
||||||
|
summary_lines.append(f"- {v['views']:,}회 · {v['title']}")
|
||||||
|
summary_lines.append("\n## 🧭 다음 액션 (자동 추천)")
|
||||||
|
for a in actions:
|
||||||
|
summary_lines.append(f"- {a}")
|
||||||
|
|
||||||
|
summary = "\n".join(summary_lines)
|
||||||
|
with open(REPORT, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n\n" + summary + "\n\n---\n")
|
||||||
|
print(f"✅ 보고서: {REPORT}")
|
||||||
|
_push_telegram(acct, summary)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 💬 댓글 수집기
|
||||||
|
|
||||||
|
`youtube_account.json`의 `WATCHED_CHANNELS`에 적은 채널들의 최근 영상에서 인기 댓글을 가져와 YouTube 에이전트의 `memory.md`에 누적 저장합니다. 시청자가 실제로 어떤 단어·반응을 쓰는지가 메모리에 쌓이면, 에이전트가 다음 영상 후크나 제목을 짤 때 그 표현을 자연스럽게 참고하게 됩니다.
|
||||||
|
|
||||||
|
## 어떻게 도와주나요?
|
||||||
|
- 📡 감시 채널마다 최근 N개 영상 → 인기 댓글 M개 가져오기
|
||||||
|
- 🧠 결과를 `_agents/youtube/memory.md`에 자동 추가 (에이전트가 다음 사이클에 자동 참조)
|
||||||
|
- 📒 같은 폴더에 `comment_harvester_report.md`로 누적 백업
|
||||||
|
|
||||||
|
## 시작하기 전 체크
|
||||||
|
- `youtube_account.json`에 `WATCHED_CHANNELS` 배열 채워두기 (예: `["@channel_a","@channel_b"]`)
|
||||||
|
- 댓글이 꺼진 영상은 자동 스킵
|
||||||
|
- API 비용: 채널당 search 1회 + 영상마다 commentThreads 1회 (가벼움)
|
||||||
|
|
||||||
|
## 설정값 (comment_harvester.json)
|
||||||
|
- `VIDEOS_PER_CHANNEL` — 채널마다 영상 몇 개 (기본 5)
|
||||||
|
- `COMMENTS_PER_VIDEO` — 영상마다 댓글 몇 개 (기본 20)
|
||||||
|
- `LOOKBACK_DAYS` — 며칠치 영상까지 (기본 14)
|
||||||
|
|
||||||
|
## 어떻게 활용되나?
|
||||||
|
메모리에 쌓인 댓글을 에이전트가 다음 한 스텝에서 자연스럽게 참고합니다. 직접 보고 싶으면 `memory.md` 또는 같은 폴더의 `comment_harvester_report.md`를 열면 돼요.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Comment Harvester — for every channel in WATCHED_CHANNELS, pulls the most
|
||||||
|
recent N videos and their top M comments. Appends the results to the agent's
|
||||||
|
memory.md so the YouTube agent can reference real audience reactions on the
|
||||||
|
next think step.
|
||||||
|
|
||||||
|
Reads from youtube_account.json (api key, watched channels) and
|
||||||
|
comment_harvester.json (volume settings)."""
|
||||||
|
import os, json, sys, time, datetime
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ACCOUNT = os.path.join(HERE, "youtube_account.json")
|
||||||
|
CONFIG = os.path.join(HERE, "comment_harvester.json")
|
||||||
|
# memory.md lives one level up — under _agents/youtube/
|
||||||
|
MEMORY = os.path.abspath(os.path.join(HERE, "..", "memory.md"))
|
||||||
|
REPORT = os.path.join(HERE, "comment_harvester_report.md")
|
||||||
|
|
||||||
|
def _load(p):
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def _resolve_channel_id(youtube, handle):
|
||||||
|
h = handle.lstrip("@")
|
||||||
|
try:
|
||||||
|
r = youtube.search().list(part="snippet", q=h, type="channel", maxResults=1).execute()
|
||||||
|
items = r.get("items", [])
|
||||||
|
if items:
|
||||||
|
return items[0]["snippet"]["channelId"], items[0]["snippet"]["title"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ {handle} 채널 조회 실패: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(ACCOUNT):
|
||||||
|
print("❌ youtube_account.json이 없어요. 먼저 그 도구로 설정.")
|
||||||
|
sys.exit(1)
|
||||||
|
acct = _load(ACCOUNT)
|
||||||
|
cfg = _load(CONFIG) if os.path.exists(CONFIG) else {}
|
||||||
|
api_key = (acct.get("YOUTUBE_API_KEY") or "").strip()
|
||||||
|
watched = acct.get("WATCHED_CHANNELS") or []
|
||||||
|
if not api_key:
|
||||||
|
print("❌ YOUTUBE_API_KEY 비어있음.")
|
||||||
|
sys.exit(1)
|
||||||
|
if not watched:
|
||||||
|
print("❌ WATCHED_CHANNELS가 비어있어요. youtube_account.json에 핸들 목록을 넣어주세요.")
|
||||||
|
print(' 예: "WATCHED_CHANNELS": ["@channel_a", "@channel_b"]')
|
||||||
|
sys.exit(1)
|
||||||
|
vids_per = int(cfg.get("VIDEOS_PER_CHANNEL", 5))
|
||||||
|
cmts_per = int(cfg.get("COMMENTS_PER_VIDEO", 20))
|
||||||
|
lookback = int(cfg.get("LOOKBACK_DAYS", 14))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pip install google-api-python-client")
|
||||||
|
sys.exit(1)
|
||||||
|
youtube = build("youtube", "v3", developerKey=api_key)
|
||||||
|
after = (datetime.datetime.utcnow() - datetime.timedelta(days=lookback)).isoformat("T") + "Z"
|
||||||
|
|
||||||
|
harvested = []
|
||||||
|
for ch in watched:
|
||||||
|
cid, ctitle = _resolve_channel_id(youtube, ch)
|
||||||
|
if not cid:
|
||||||
|
continue
|
||||||
|
print(f"📡 [{ch}] 최근 영상 {vids_per}개 가져오는 중...")
|
||||||
|
sr = youtube.search().list(part="snippet", channelId=cid, maxResults=vids_per,
|
||||||
|
order="date", publishedAfter=after, type="video").execute()
|
||||||
|
for it in sr.get("items", []):
|
||||||
|
vid = it["id"]["videoId"]
|
||||||
|
vtitle = it["snippet"]["title"]
|
||||||
|
print(f" 💬 {vtitle[:60]}")
|
||||||
|
try:
|
||||||
|
cr = youtube.commentThreads().list(part="snippet", videoId=vid,
|
||||||
|
maxResults=cmts_per, order="relevance",
|
||||||
|
textFormat="plainText").execute()
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "commentsDisabled" in msg or "disabled" in msg.lower():
|
||||||
|
continue
|
||||||
|
print(f" ⚠️ 댓글 가져오기 실패: {e}")
|
||||||
|
continue
|
||||||
|
comments = []
|
||||||
|
for ci in cr.get("items", []):
|
||||||
|
top = ci["snippet"]["topLevelComment"]["snippet"]
|
||||||
|
comments.append({
|
||||||
|
"author": top.get("authorDisplayName", ""),
|
||||||
|
"likes": int(top.get("likeCount", 0)),
|
||||||
|
"text": (top.get("textDisplay", "") or "")[:280],
|
||||||
|
})
|
||||||
|
harvested.append({
|
||||||
|
"channel": ch, "channel_title": ctitle,
|
||||||
|
"video": vtitle, "video_id": vid, "comments": comments,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not harvested:
|
||||||
|
print("⚠️ 수집된 댓글 없음.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
ts = time.strftime('%Y-%m-%d %H:%M')
|
||||||
|
md_lines = [f"\n## 💬 시청자 댓글 수집 — {ts}"]
|
||||||
|
for h in harvested:
|
||||||
|
md_lines.append(f"\n### {h['channel_title']} ({h['channel']}) — {h['video']}")
|
||||||
|
md_lines.append(f"https://youtu.be/{h['video_id']}")
|
||||||
|
for c in h["comments"][:10]:
|
||||||
|
md_lines.append(f"- ({c['likes']}❤) **{c['author']}**: {c['text']}")
|
||||||
|
block = "\n".join(md_lines)
|
||||||
|
|
||||||
|
# Append to memory so the agent uses these comments next think.
|
||||||
|
os.makedirs(os.path.dirname(MEMORY), exist_ok=True)
|
||||||
|
if not os.path.exists(MEMORY):
|
||||||
|
with open(MEMORY, "w", encoding="utf-8") as f:
|
||||||
|
f.write("# YouTube 에이전트 — 메모리\n\n")
|
||||||
|
with open(MEMORY, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n" + block + "\n")
|
||||||
|
with open(REPORT, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n" + block + "\n\n---\n")
|
||||||
|
print(f"\n✅ 메모리에 추가: {MEMORY}")
|
||||||
|
print(f"✅ 보고서: {REPORT}")
|
||||||
|
print(f" {len(harvested)}개 영상 · 평균 {sum(len(h['comments']) for h in harvested)//max(len(harvested),1)}개 댓글")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 🔭 경쟁 채널 분석
|
||||||
|
|
||||||
|
`youtube_account.json`의 `COMPETITOR_CHANNELS`에 적은 경쟁 채널들의 최근 떡상 영상을 모아서, 로컬 LLM에게 **지시문 형식**의 다음 액션 브리프를 받아옵니다 — "이거 해야합니다 / 저거 해야합니다 / 이건 절대 하지 마세요" 형태로 나옵니다.
|
||||||
|
|
||||||
|
## 어떻게 도와주나요?
|
||||||
|
- 🔭 경쟁 채널마다 최근 N개 인기 영상(view 기준) 수집
|
||||||
|
- 🧠 로컬 LLM이 패턴을 읽고 4섹션으로 브리프 작성:
|
||||||
|
- 1) 지금 당장 해야 하는 것 3개
|
||||||
|
- 2) 이번 주 시도할 것 3개 (제목 후보 포함)
|
||||||
|
- 3) 절대 하지 말 것 1개
|
||||||
|
- 4) 다음 영상 핵심 한 줄
|
||||||
|
- 📨 텔레그램 설정돼있으면 자동 푸시
|
||||||
|
|
||||||
|
## 시작하기 전 체크
|
||||||
|
- `youtube_account.json`의 `COMPETITOR_CHANNELS` 채워두기
|
||||||
|
- 로컬 LLM(Ollama/LM Studio)이 켜져 있어야 함
|
||||||
|
|
||||||
|
## 설정값 (competitor_brief.json)
|
||||||
|
- `TOP_N_PER_CHANNEL` — 채널마다 상위 영상 몇 개 (기본 5)
|
||||||
|
- `LOOKBACK_DAYS` — 며칠치 (기본 30)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Competitor Brief — for every channel in COMPETITOR_CHANNELS, pulls their
|
||||||
|
recent top-performing videos and asks the local LLM for a *prescriptive*
|
||||||
|
brief: what should YOU do next, given what's working for them.
|
||||||
|
|
||||||
|
Reads youtube_account.json (api key, competitors, ollama, model) and
|
||||||
|
competitor_brief.json (volume)."""
|
||||||
|
import os, json, sys, time, datetime
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ACCOUNT = os.path.join(HERE, "youtube_account.json")
|
||||||
|
CONFIG = os.path.join(HERE, "competitor_brief.json")
|
||||||
|
REPORT = os.path.join(HERE, "competitor_brief_report.md")
|
||||||
|
|
||||||
|
def _load(p):
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def _resolve_channel_id(youtube, handle):
|
||||||
|
h = handle.lstrip("@")
|
||||||
|
try:
|
||||||
|
r = youtube.search().list(part="snippet", q=h, type="channel", maxResults=1).execute()
|
||||||
|
items = r.get("items", [])
|
||||||
|
if items:
|
||||||
|
return items[0]["snippet"]["channelId"], items[0]["snippet"]["title"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _push_telegram(account, text):
|
||||||
|
token = (account.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
chat = (account.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
if not token or not chat:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
requests.post(f"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
json={"chat_id": chat, "text": text[:4000], "parse_mode": "Markdown"},
|
||||||
|
timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(ACCOUNT):
|
||||||
|
print("❌ youtube_account.json이 없어요.")
|
||||||
|
sys.exit(1)
|
||||||
|
acct = _load(ACCOUNT)
|
||||||
|
cfg = _load(CONFIG) if os.path.exists(CONFIG) else {}
|
||||||
|
api_key = (acct.get("YOUTUBE_API_KEY") or "").strip()
|
||||||
|
competitors = acct.get("COMPETITOR_CHANNELS") or []
|
||||||
|
if not api_key:
|
||||||
|
print("❌ YOUTUBE_API_KEY 비어있음.")
|
||||||
|
sys.exit(1)
|
||||||
|
if not competitors:
|
||||||
|
print("❌ COMPETITOR_CHANNELS가 비어있어요. youtube_account.json에 채워주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
top_n = int(cfg.get("TOP_N_PER_CHANNEL", 5))
|
||||||
|
lookback = int(cfg.get("LOOKBACK_DAYS", 30))
|
||||||
|
ollama_url = (acct.get("OLLAMA_URL") or "http://127.0.0.1:11434").rstrip("/")
|
||||||
|
model = acct.get("MODEL") or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pip install google-api-python-client requests")
|
||||||
|
sys.exit(1)
|
||||||
|
youtube = build("youtube", "v3", developerKey=api_key)
|
||||||
|
after = (datetime.datetime.utcnow() - datetime.timedelta(days=lookback)).isoformat("T") + "Z"
|
||||||
|
|
||||||
|
snapshot = []
|
||||||
|
for ch in competitors:
|
||||||
|
cid, ctitle = _resolve_channel_id(youtube, ch)
|
||||||
|
if not cid:
|
||||||
|
print(f"⚠️ {ch} 채널 못 찾음")
|
||||||
|
continue
|
||||||
|
print(f"🔭 [{ch}] 최근 영상 분석 중...")
|
||||||
|
sr = youtube.search().list(part="snippet", channelId=cid, maxResults=top_n,
|
||||||
|
order="viewCount", publishedAfter=after, type="video").execute()
|
||||||
|
ids = [it["id"]["videoId"] for it in sr.get("items", [])]
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
st = youtube.videos().list(part="statistics,snippet", id=",".join(ids)).execute()
|
||||||
|
for it in st.get("items", []):
|
||||||
|
stats = it.get("statistics", {})
|
||||||
|
snip = it.get("snippet", {})
|
||||||
|
snapshot.append({
|
||||||
|
"channel": ctitle,
|
||||||
|
"title": snip.get("title", ""),
|
||||||
|
"views": int(stats.get("viewCount", 0)),
|
||||||
|
"published": snip.get("publishedAt", "")[:10],
|
||||||
|
})
|
||||||
|
|
||||||
|
if not snapshot:
|
||||||
|
print("❌ 데이터 수집 실패.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
snapshot.sort(key=lambda r: r["views"], reverse=True)
|
||||||
|
data_text = "\n".join(f"[{r['channel']}] {r['views']:,}회 · {r['published']} · {r['title']}"
|
||||||
|
for r in snapshot[:25])
|
||||||
|
|
||||||
|
if not model:
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{ollama_url}/api/tags", timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
models = [m["name"] for m in r.json().get("models", [])]
|
||||||
|
if not models:
|
||||||
|
print("❌ 로컬 LLM에 모델이 없어요.")
|
||||||
|
sys.exit(1)
|
||||||
|
model = models[0]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ LLM 연결 실패: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
prompt = f"""당신은 유튜브 알고리즘 전략가입니다. 아래는 경쟁 채널들의 최근 {lookback}일간 상위 영상 데이터입니다.
|
||||||
|
|
||||||
|
[경쟁 데이터]
|
||||||
|
{data_text}
|
||||||
|
|
||||||
|
이 채널 운영자에게 **지시문 형식**으로 다음을 작성하세요. 모호한 조언 금지, 구체적이고 실행 가능한 지시.
|
||||||
|
|
||||||
|
## 1) 지금 당장 해야 하는 것 (3개)
|
||||||
|
- 각 항목: "~을(를) 하세요. 왜냐하면 …"
|
||||||
|
|
||||||
|
## 2) 이번 주 안에 시도해야 하는 것 (3개)
|
||||||
|
- 각 항목: 구체적 영상 제목 후보 또는 후크 문장 포함
|
||||||
|
|
||||||
|
## 3) 절대 하지 말아야 할 것 (1개)
|
||||||
|
- 경쟁사 데이터에서 보이는 함정 패턴
|
||||||
|
|
||||||
|
## 4) 한 줄 요약
|
||||||
|
- 다음 영상의 핵심 컨셉을 한 문장으로
|
||||||
|
"""
|
||||||
|
print("🧠 [LLM 분석 중...]")
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{ollama_url}/api/generate",
|
||||||
|
json={"model": model, "prompt": prompt, "stream": False},
|
||||||
|
timeout=240)
|
||||||
|
r.raise_for_status()
|
||||||
|
brief = r.json().get("response", "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ LLM 실패: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ts = time.strftime('%Y-%m-%d %H:%M')
|
||||||
|
out = f"# 🔭 경쟁 채널 브리프 — {ts}\n\n채널: {', '.join(competitors)} · 최근 {lookback}일\n\n{brief}\n"
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(out)
|
||||||
|
print("="*60)
|
||||||
|
with open(REPORT, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n\n" + out + "\n---\n")
|
||||||
|
print(f"\n✅ 보고서: {REPORT}")
|
||||||
|
_push_telegram(acct, out)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 📊 내 유튜브 채널 분석
|
||||||
|
|
||||||
|
본인 채널의 최근 영상이 잘 올라갔는지 한눈에 봅니다. 조회수 중간값을 기준선으로 삼아 떡상/부진 영상을 자동 분류하고, 다음에 뭘 할지 짧은 제안까지 만들어줘요.
|
||||||
|
|
||||||
|
## 어떻게 도와주나요?
|
||||||
|
- 🎬 본인 채널 최근 N개 영상 메타·통계 수집
|
||||||
|
- 📊 조회수 **중간값** 계산 → 1.5배 이상 = 🔥 떡상, 0.5배 미만 = 🥶 부진
|
||||||
|
- 🧭 떡상/부진 비율 보고 다음 액션 1~3개 제안
|
||||||
|
- 📨 `youtube_account.json`에 텔레그램이 설정돼있으면 보고를 메시지로도 보내줌
|
||||||
|
|
||||||
|
## 시작하기 전 체크
|
||||||
|
- `youtube_account.json`의 `YOUTUBE_API_KEY` + `MY_CHANNEL_HANDLE` 또는 `MY_CHANNEL_ID` 채워야 함
|
||||||
|
- 핸들만 있어도 자동으로 채널 ID를 조회합니다 (검색 1회 사용)
|
||||||
|
|
||||||
|
## 설정값 (my_videos_check.json)
|
||||||
|
- `LOOKBACK_DAYS` — 며칠치 영상 볼지 (기본 30)
|
||||||
|
- `TOP_N` — 최대 몇 개 분석할지 (기본 10)
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
- 콘솔에 영상별 조회수·라이크·댓글 수
|
||||||
|
- `my_videos_check_report.md`에 누적 저장
|
||||||
|
- (선택) 텔레그램 알림
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Professional YouTube Channel Analysis — pro_v4.
|
||||||
|
|
||||||
|
채널 메타 · 영상별 상세 (조회수·좋아요율·댓글율·길이·요일) · 상위/하위 영상의 패턴 ·
|
||||||
|
인기 댓글 샘플 · 발행 요일 분석 · 제목 키워드 · 우선순위 액션 추천. 모든 분석은
|
||||||
|
실제 YouTube Data API 호출 결과 기반.
|
||||||
|
|
||||||
|
Reads YOUTUBE_API_KEY + MY_CHANNEL_HANDLE/ID from youtube_account.json.
|
||||||
|
Reads LOOKBACK_DAYS / TOP_N / COMMENT_SAMPLES from my_videos_check.json."""
|
||||||
|
import os, json, sys, time, datetime, re, statistics, warnings, html as html_lib
|
||||||
|
from collections import Counter
|
||||||
|
# v2.89.49 — DeprecationWarning(utcnow 등) 노이즈 제거. 사용자 채팅창 출력에 끼면 못생김.
|
||||||
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ACCOUNT = os.path.join(HERE, "youtube_account.json")
|
||||||
|
CONFIG = os.path.join(HERE, "my_videos_check.json")
|
||||||
|
REPORT = os.path.join(HERE, "my_videos_check_report.md")
|
||||||
|
|
||||||
|
def _load(p):
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def _resolve_channel_id(youtube, handle, channel_id):
|
||||||
|
if channel_id:
|
||||||
|
return channel_id
|
||||||
|
if not handle:
|
||||||
|
return None
|
||||||
|
h = handle.lstrip("@")
|
||||||
|
try:
|
||||||
|
r = youtube.search().list(part="snippet", q=h, type="channel", maxResults=1).execute()
|
||||||
|
items = r.get("items", [])
|
||||||
|
if items:
|
||||||
|
return items[0]["snippet"]["channelId"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 채널 ID 조회 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_telegram(account):
|
||||||
|
"""telegram_v3 — Secretary's tools/telegram_setup.json is the canonical
|
||||||
|
UI-managed home (input via Skills ⚙️). Fallback chain:
|
||||||
|
1) youtube_account.json (this tool's local override, back-compat)
|
||||||
|
2) _agents/secretary/tools/telegram_setup.json (UI-managed, canonical)
|
||||||
|
3) _agents/secretary/config.md (legacy markdown, back-compat)
|
||||||
|
"""
|
||||||
|
import re, json as _json
|
||||||
|
token = (account.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
chat = (account.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
if token and chat:
|
||||||
|
return token, chat
|
||||||
|
brain_root = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
||||||
|
# 2) Secretary's tool JSON
|
||||||
|
sec_json = os.path.join(brain_root, "_agents", "secretary", "tools", "telegram_setup.json")
|
||||||
|
if (not token or not chat) and os.path.exists(sec_json):
|
||||||
|
try:
|
||||||
|
with open(sec_json, "r", encoding="utf-8") as f:
|
||||||
|
cfg = _json.load(f)
|
||||||
|
if not token: token = (cfg.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
if not chat: chat = (cfg.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 3) Legacy config.md
|
||||||
|
sec_cfg = os.path.join(brain_root, "_agents", "secretary", "config.md")
|
||||||
|
if (not token or not chat) and os.path.exists(sec_cfg):
|
||||||
|
try:
|
||||||
|
with open(sec_cfg, "r", encoding="utf-8") as f:
|
||||||
|
txt = f.read()
|
||||||
|
if not token:
|
||||||
|
m = re.search(r"TELEGRAM_BOT_TOKEN\s*[::=]\s*([A-Za-z0-9:_\-]+)", txt)
|
||||||
|
if m: token = m.group(1).strip()
|
||||||
|
if not chat:
|
||||||
|
m = re.search(r"TELEGRAM_CHAT_ID\s*[::=]\s*(-?\d+)", txt)
|
||||||
|
if m: chat = m.group(1).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return token, chat
|
||||||
|
|
||||||
|
def _push_telegram(account, text):
|
||||||
|
"""v2.89.49 — 마크다운 모드는 *,[,(,),# 같은 특수문자 많은 보고서에서 자주 400 거부.
|
||||||
|
이전엔 그래도 'sent' print해서 사용자한테 가짜 성공 보고. 이제 plain text 모드로
|
||||||
|
안전하게 보내고 HTTP status 체크해서 진짜 성공/실패 정확히 알려줌."""
|
||||||
|
token, chat = _resolve_telegram(account)
|
||||||
|
if not token or not chat:
|
||||||
|
print("⚠️ 텔레그램 토큰/chat_id 미설정 — 전송 안 함", file=sys.stderr)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
# plain text (parse_mode 없음) — 어떤 특수문자든 통과
|
||||||
|
r = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
json={"chat_id": chat, "text": text[:4000]},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
print("📨 텔레그램 전송 성공", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
err = r.json().get("description", r.text[:200])
|
||||||
|
except Exception:
|
||||||
|
err = r.text[:200]
|
||||||
|
print(f"⚠️ 텔레그램 전송 실패 (HTTP {r.status_code}): {err}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 텔레그램 전송 에러: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def _fmt_num(n):
|
||||||
|
if n >= 1_000_000: return f"{n/1_000_000:.1f}M"
|
||||||
|
if n >= 1_000: return f"{n/1_000:.1f}K"
|
||||||
|
return f"{n:,}"
|
||||||
|
|
||||||
|
def _parse_duration(iso):
|
||||||
|
"""ISO 8601 duration (PT5M30S) → seconds"""
|
||||||
|
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', iso or '')
|
||||||
|
if not m: return 0
|
||||||
|
h, mn, s = (int(x) if x else 0 for x in m.groups())
|
||||||
|
return h * 3600 + mn * 60 + s
|
||||||
|
|
||||||
|
def _fmt_duration(secs):
|
||||||
|
if secs >= 3600: return f"{secs//3600}시간 {(secs%3600)//60}분"
|
||||||
|
if secs >= 60: return f"{secs//60}분 {secs%60}초"
|
||||||
|
return f"{secs}초"
|
||||||
|
|
||||||
|
def _korean_weekday(dt):
|
||||||
|
return ["월","화","수","목","금","토","일"][dt.weekday()]
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(ACCOUNT):
|
||||||
|
print("❌ youtube_account.json이 없어요. 직원 에이전트 보기 → YouTube → 도구 ⚙️에서 API 키와 채널 ID를 입력하세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
acct = _load(ACCOUNT)
|
||||||
|
cfg = _load(CONFIG) if os.path.exists(CONFIG) else {}
|
||||||
|
api_key = (acct.get("YOUTUBE_API_KEY") or "").strip()
|
||||||
|
handle = (acct.get("MY_CHANNEL_HANDLE") or "").strip()
|
||||||
|
chan_id = (acct.get("MY_CHANNEL_ID") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
print("❌ YOUTUBE_API_KEY 미설정. youtube_account.json에 채워주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
if not (handle or chan_id):
|
||||||
|
print("❌ MY_CHANNEL_HANDLE 또는 MY_CHANNEL_ID 필요.")
|
||||||
|
sys.exit(1)
|
||||||
|
lookback = int(cfg.get("LOOKBACK_DAYS", 30))
|
||||||
|
top_n = int(cfg.get("TOP_N", 15))
|
||||||
|
comment_samples = int(cfg.get("COMMENT_SAMPLES", 5))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
except ImportError:
|
||||||
|
print("❌ google-api-python-client 미설치. pip install google-api-python-client requests")
|
||||||
|
sys.exit(1)
|
||||||
|
youtube = build("youtube", "v3", developerKey=api_key)
|
||||||
|
|
||||||
|
cid = _resolve_channel_id(youtube, handle, chan_id)
|
||||||
|
if not cid:
|
||||||
|
print("❌ 채널 ID를 찾지 못했어요. youtube_account.json의 핸들/ID 확인.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# === 1. 채널 메타 ===
|
||||||
|
print(f"🔍 채널 정보 가져오는 중...", file=sys.stderr)
|
||||||
|
cr = youtube.channels().list(part="snippet,statistics,contentDetails,brandingSettings", id=cid).execute()
|
||||||
|
cit = cr.get("items", [])
|
||||||
|
if not cit:
|
||||||
|
print(f"❌ 채널 데이터 없음 (ID: {cid})")
|
||||||
|
sys.exit(1)
|
||||||
|
ch = cit[0]
|
||||||
|
snip = ch.get("snippet", {})
|
||||||
|
cstats = ch.get("statistics", {})
|
||||||
|
# v2.89.55 — YouTube API가 가끔 & / ' 같은 HTML entity로 인코딩된 제목 반환.
|
||||||
|
# 이걸 그대로 출력하면 채팅창에서 "'" 가 literal로 보임. 미리 디코드.
|
||||||
|
ch_title = html_lib.unescape(snip.get("title", "") or "")
|
||||||
|
custom_url = snip.get("customUrl", "")
|
||||||
|
published = (snip.get("publishedAt", "") or "")[:10]
|
||||||
|
country = snip.get("country", "")
|
||||||
|
sub_count = int(cstats.get("subscriberCount", 0))
|
||||||
|
subs_hidden = cstats.get("hiddenSubscriberCount", False)
|
||||||
|
view_count_total = int(cstats.get("viewCount", 0))
|
||||||
|
video_count_total = int(cstats.get("videoCount", 0))
|
||||||
|
if published:
|
||||||
|
try:
|
||||||
|
age_days = (datetime.date.today() - datetime.date.fromisoformat(published)).days
|
||||||
|
except Exception:
|
||||||
|
age_days = 0
|
||||||
|
else:
|
||||||
|
age_days = 0
|
||||||
|
age_years = age_days / 365.25 if age_days > 0 else 0
|
||||||
|
avg_views_per_video_alltime = view_count_total // video_count_total if video_count_total else 0
|
||||||
|
|
||||||
|
# === 2. 최근 영상 목록 ===
|
||||||
|
print(f"🔍 최근 {lookback}일 영상 가져오는 중...", file=sys.stderr)
|
||||||
|
after = (datetime.datetime.utcnow() - datetime.timedelta(days=lookback)).isoformat("T") + "Z"
|
||||||
|
sr = youtube.search().list(part="snippet", channelId=cid, maxResults=top_n,
|
||||||
|
order="date", publishedAfter=after, type="video").execute()
|
||||||
|
vids = [(it["id"]["videoId"], it["snippet"]["title"], it["snippet"]["publishedAt"])
|
||||||
|
for it in sr.get("items", [])]
|
||||||
|
if not vids:
|
||||||
|
# Fallback to most recent regardless of lookback window
|
||||||
|
sr = youtube.search().list(part="snippet", channelId=cid, maxResults=top_n,
|
||||||
|
order="date", type="video").execute()
|
||||||
|
vids = [(it["id"]["videoId"], it["snippet"]["title"], it["snippet"]["publishedAt"])
|
||||||
|
for it in sr.get("items", [])]
|
||||||
|
if not vids:
|
||||||
|
# v2.89.55 — 빈 영상 시 stderr로. stdout이 비어 있어야 TS shortcut이 실패로 정확히 처리.
|
||||||
|
print(f"⚠️ 업로드된 영상이 없어요.", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# === 3. 영상 상세 통계 ===
|
||||||
|
print(f"🔍 영상 {len(vids)}개 상세 통계 + 길이·태그 가져오는 중...", file=sys.stderr)
|
||||||
|
vstats = youtube.videos().list(
|
||||||
|
part="statistics,contentDetails,snippet",
|
||||||
|
id=",".join(v[0] for v in vids)
|
||||||
|
).execute()
|
||||||
|
sm = {it["id"]: it for it in vstats.get("items", [])}
|
||||||
|
rows = []
|
||||||
|
for vid, vtitle, pub in vids:
|
||||||
|
item = sm.get(vid, {})
|
||||||
|
s = item.get("statistics", {})
|
||||||
|
cd = item.get("contentDetails", {})
|
||||||
|
sn = item.get("snippet", {})
|
||||||
|
views = int(s.get("viewCount", 0))
|
||||||
|
likes = int(s.get("likeCount", 0))
|
||||||
|
comments = int(s.get("commentCount", 0))
|
||||||
|
dur_sec = _parse_duration(cd.get("duration", "PT0S"))
|
||||||
|
like_rate = (likes / views * 100) if views > 0 else 0
|
||||||
|
comment_rate = (comments / views * 100) if views > 0 else 0
|
||||||
|
try:
|
||||||
|
pub_dt = datetime.datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
||||||
|
weekday = _korean_weekday(pub_dt)
|
||||||
|
hour = pub_dt.hour
|
||||||
|
except Exception:
|
||||||
|
weekday, hour = "-", 0
|
||||||
|
rows.append({
|
||||||
|
# v2.89.55 — title HTML entity 디코드 (' → ', & → & 등)
|
||||||
|
"id": vid, "title": html_lib.unescape(vtitle or ""), "pub": pub[:10],
|
||||||
|
"weekday": weekday, "hour": hour,
|
||||||
|
"views": views, "likes": likes, "comments": comments,
|
||||||
|
"duration_sec": dur_sec,
|
||||||
|
"like_rate": like_rate, "comment_rate": comment_rate,
|
||||||
|
"tags": sn.get("tags", []) or [],
|
||||||
|
"is_short": dur_sec <= 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
# === 4. 집계 ===
|
||||||
|
views_list = [r["views"] for r in rows]
|
||||||
|
median_views = int(statistics.median(views_list)) if views_list else 0
|
||||||
|
avg_views = int(statistics.mean(views_list)) if views_list else 0
|
||||||
|
avg_likes = int(statistics.mean([r["likes"] for r in rows])) if rows else 0
|
||||||
|
avg_comments = int(statistics.mean([r["comments"] for r in rows])) if rows else 0
|
||||||
|
avg_duration = int(statistics.mean([r["duration_sec"] for r in rows])) if rows else 0
|
||||||
|
avg_like_rate = statistics.mean([r["like_rate"] for r in rows]) if rows else 0
|
||||||
|
avg_comment_rate = statistics.mean([r["comment_rate"] for r in rows]) if rows else 0
|
||||||
|
title_lengths = [len(r["title"]) for r in rows]
|
||||||
|
avg_title_len = int(statistics.mean(title_lengths)) if title_lengths else 0
|
||||||
|
shorts_count = sum(1 for r in rows if r["is_short"])
|
||||||
|
|
||||||
|
rows_sorted = sorted(rows, key=lambda r: r["views"], reverse=True)
|
||||||
|
top_videos = rows_sorted[:3]
|
||||||
|
bottom_videos = rows_sorted[-3:][::-1] if len(rows_sorted) >= 4 else []
|
||||||
|
|
||||||
|
# 요일·시간대 패턴
|
||||||
|
weekday_views = {}
|
||||||
|
for r in rows:
|
||||||
|
weekday_views.setdefault(r["weekday"], []).append(r["views"])
|
||||||
|
weekday_avg = {wd: int(statistics.mean(vs)) for wd, vs in weekday_views.items()}
|
||||||
|
|
||||||
|
# 상위 영상 제목 키워드
|
||||||
|
top_title_words = Counter()
|
||||||
|
stop_kr = {'그리고','근데','너무','진짜','정말','내가','지금','이거','저는','제가','우리'}
|
||||||
|
stop_en = {'this','that','and','the','for','with','have','will','your','from','about'}
|
||||||
|
for r in top_videos:
|
||||||
|
words = re.findall(r'[가-힣]+|[a-zA-Z]+', r["title"])
|
||||||
|
top_title_words.update(w for w in words if len(w) >= 2 and w.lower() not in stop_en and w not in stop_kr)
|
||||||
|
top_keywords = [w for w, _ in top_title_words.most_common(8)]
|
||||||
|
|
||||||
|
# === 5. 인기 댓글 샘플 (상위 3개 영상) ===
|
||||||
|
print(f"💬 상위 영상의 인기 댓글 가져오는 중...", file=sys.stderr)
|
||||||
|
comments_by_video = {}
|
||||||
|
for r in top_videos[:3]:
|
||||||
|
try:
|
||||||
|
cr_resp = youtube.commentThreads().list(
|
||||||
|
part="snippet", videoId=r["id"], maxResults=comment_samples, order="relevance"
|
||||||
|
).execute()
|
||||||
|
comments_by_video[r["id"]] = [
|
||||||
|
{
|
||||||
|
# v2.89.55 — author/text도 HTML entity 디코드
|
||||||
|
"author": html_lib.unescape(c["snippet"]["topLevelComment"]["snippet"].get("authorDisplayName", "") or ""),
|
||||||
|
"text": html_lib.unescape(c["snippet"]["topLevelComment"]["snippet"].get("textOriginal", "") or "")[:200],
|
||||||
|
"likes": int(c["snippet"]["topLevelComment"]["snippet"].get("likeCount", 0)),
|
||||||
|
}
|
||||||
|
for c in cr_resp.get("items", [])
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
comments_by_video[r["id"]] = [] # 댓글 비활성 영상이면 403
|
||||||
|
|
||||||
|
# === 6. 종합 보고서 ===
|
||||||
|
# v2.89.50 — 시각적으로 더 멋진 레이아웃. 블록인용·이모지 평가·시각 분리선 활용.
|
||||||
|
sub_str = "비공개" if subs_hidden else f"{_fmt_num(sub_count)}명"
|
||||||
|
like_rating = "🟢 좋음" if avg_like_rate >= 2.0 else ("🟡 보통" if avg_like_rate >= 1.0 else "🔴 개선")
|
||||||
|
comment_rating = "🟢 좋음" if avg_comment_rate >= 0.5 else ("🟡 보통" if avg_comment_rate >= 0.2 else "🔴 개선")
|
||||||
|
L = []
|
||||||
|
L.append(f"# 🎬 {ch_title}")
|
||||||
|
L.append(f"_{time.strftime('%Y-%m-%d %H:%M')} · 최근 {lookback}일 분석 · 영상 {len(rows)}개_")
|
||||||
|
L.append("")
|
||||||
|
# 채널 메타 — 인용 블록으로 한눈에
|
||||||
|
L.append(f"> **{sub_str}** 구독자 · **{_fmt_num(view_count_total)}** 누적 조회 · **{video_count_total:,}개** 영상" + (f" · **{age_years:.1f}년** 운영" if age_years > 0 else ""))
|
||||||
|
L.append(f"> 핸들 `{custom_url or handle or '-'}`" + (f" · 🌍 {country}" if country else "") + f" · 영상당 평균 **{_fmt_num(avg_views_per_video_alltime)}** 조회")
|
||||||
|
L.append("")
|
||||||
|
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 최근 성과 요약 — 카드 스타일
|
||||||
|
L.append(f"## 📊 최근 {lookback}일 성과 한눈에")
|
||||||
|
L.append("")
|
||||||
|
L.append("| 지표 | 값 | 평가 |")
|
||||||
|
L.append("|---|---|---|")
|
||||||
|
pace = (len(rows) * 30 / lookback) if lookback > 0 else 0
|
||||||
|
pace_rating = "🟢 활발" if pace >= 4 else ("🟡 보통" if pace >= 2 else "🔴 저조")
|
||||||
|
L.append(f"| 업로드 | {len(rows)}개 (월 {pace:.1f}개) | {pace_rating} |")
|
||||||
|
if rows:
|
||||||
|
L.append(f"| 조회수 중간값 | **{_fmt_num(median_views)}** | 최고 {_fmt_num(rows_sorted[0]['views'])} · 최저 {_fmt_num(rows_sorted[-1]['views'])} |")
|
||||||
|
L.append(f"| 좋아요율 | **{avg_like_rate:.2f}%** | {like_rating} (업계 2~5%) |")
|
||||||
|
L.append(f"| 댓글율 | **{avg_comment_rate:.2f}%** | {comment_rating} (업계 0.3~1%) |")
|
||||||
|
L.append(f"| 평균 길이 | {_fmt_duration(avg_duration)} | 제목 평균 {avg_title_len}자 |")
|
||||||
|
if shorts_count:
|
||||||
|
L.append(f"| Shorts | {shorts_count}개 / {len(rows)} | - |")
|
||||||
|
L.append("")
|
||||||
|
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 영상별 상세 표
|
||||||
|
L.append("## 📺 영상별 상세 (조회수 순)")
|
||||||
|
L.append("| # | 조회수 | 좋아요 (율) | 댓글 (율) | 길이 | 발행 | 제목 |")
|
||||||
|
L.append("|---|---|---|---|---|---|---|")
|
||||||
|
for i, r in enumerate(rows_sorted, 1):
|
||||||
|
marker = "🔥" if r["views"] >= median_views * 1.5 else ("👍" if r["views"] >= median_views else "🥶")
|
||||||
|
title_short = r['title'].replace('|', '\\|')[:60]
|
||||||
|
L.append(f"| {i}{marker} | {_fmt_num(r['views'])} | {_fmt_num(r['likes'])} ({r['like_rate']:.1f}%) | {_fmt_num(r['comments'])} ({r['comment_rate']:.1f}%) | {_fmt_duration(r['duration_sec'])} | {r['pub']}({r['weekday']}) | {title_short} |")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 상위 영상 심층 분석 — 카드 스타일 + 메달 이모지
|
||||||
|
L.append("## 🏆 TOP 3 — 무엇이 잘 됐나")
|
||||||
|
L.append("")
|
||||||
|
medals = ["🥇", "🥈", "🥉"]
|
||||||
|
for idx, r in enumerate(top_videos):
|
||||||
|
medal = medals[idx] if idx < 3 else "👍"
|
||||||
|
L.append(f"### {medal} {_fmt_num(r['views'])}회 · {r['title']}")
|
||||||
|
L.append("")
|
||||||
|
L.append(f"> 📅 {r['pub']} ({r['weekday']}요일 {r['hour']:02d}시) · ⏱ {_fmt_duration(r['duration_sec'])} · 👍 {r['like_rate']:.2f}% · 💬 {r['comment_rate']:.2f}%")
|
||||||
|
if r['tags']:
|
||||||
|
tag_str = ' '.join(f"`{t}`" for t in r['tags'][:5])
|
||||||
|
L.append(f"> 🏷 {tag_str}" + (' …' if len(r['tags']) > 5 else ''))
|
||||||
|
L.append(f"> 🔗 [영상 보기](https://youtu.be/{r['id']}) · 🖼 [썸네일](https://i.ytimg.com/vi/{r['id']}/mqdefault.jpg)")
|
||||||
|
cs = comments_by_video.get(r["id"], [])
|
||||||
|
if cs:
|
||||||
|
L.append("")
|
||||||
|
L.append("**💬 인기 댓글:**")
|
||||||
|
for c in cs[:3]:
|
||||||
|
txt = c['text'].replace(chr(10), ' ').replace(chr(13), ' ')[:140]
|
||||||
|
L.append(f"> _{c['author']}_ (👍{c['likes']}): {txt}")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 하위 영상 — 시각적으로 부진 강조
|
||||||
|
if bottom_videos:
|
||||||
|
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
L.append("")
|
||||||
|
L.append("## 🥶 하위 영상 — 개선 필요")
|
||||||
|
L.append("")
|
||||||
|
for r in bottom_videos:
|
||||||
|
gap_pct = int((1 - r['views'] / median_views) * 100) if median_views else 0
|
||||||
|
L.append(f"- **{_fmt_num(r['views'])}회** · 중간값 대비 **-{gap_pct}%** ↓")
|
||||||
|
L.append(f" - {r['title']}")
|
||||||
|
L.append(f" - 📅 {r['pub']}({r['weekday']}, {r['hour']:02d}시) · ⏱ {_fmt_duration(r['duration_sec'])} · 🔗 [영상](https://youtu.be/{r['id']})")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 패턴
|
||||||
|
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
L.append("")
|
||||||
|
L.append("## 🔍 패턴 분석")
|
||||||
|
L.append("")
|
||||||
|
if weekday_avg and len(weekday_avg) >= 2:
|
||||||
|
best_day = max(weekday_avg.items(), key=lambda x: x[1])
|
||||||
|
worst_day = min(weekday_avg.items(), key=lambda x: x[1])
|
||||||
|
ratio = best_day[1] / worst_day[1] if worst_day[1] else 1
|
||||||
|
L.append(f"- 📅 **최고 요일**: {best_day[0]}요일 (평균 {_fmt_num(best_day[1])}회) — 최저 대비 **{ratio:.1f}배**")
|
||||||
|
L.append(f"- 📅 **최저 요일**: {worst_day[0]}요일 (평균 {_fmt_num(worst_day[1])}회)")
|
||||||
|
if top_keywords:
|
||||||
|
L.append(f"- 🔑 **상위 영상 키워드**: {' '.join('`'+k+'`' for k in top_keywords)}")
|
||||||
|
if title_lengths:
|
||||||
|
L.append(f"- 📝 **제목 길이**: 평균 {avg_title_len}자 (최단 {min(title_lengths)}자 · 최장 {max(title_lengths)}자)")
|
||||||
|
if avg_duration > 0:
|
||||||
|
L.append(f"- ⏱ **영상 길이**: 평균 {_fmt_duration(avg_duration)}" + (f" · Shorts(60초 이하) {shorts_count}/{len(rows)}개" if shorts_count else ""))
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 액션 추천 — 카드 스타일
|
||||||
|
L.append("")
|
||||||
|
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
L.append("")
|
||||||
|
L.append("## 🎯 다음 액션 (우선순위)")
|
||||||
|
L.append("")
|
||||||
|
recs = []
|
||||||
|
if bottom_videos:
|
||||||
|
worst = bottom_videos[0]
|
||||||
|
recs.append(("🔴", f"**부진 영상 살리기** — `{worst['title'][:40]}` ({_fmt_num(worst['views'])}회). 썸네일 A/B 또는 제목 리네이밍."))
|
||||||
|
if top_videos:
|
||||||
|
winner = top_videos[0]
|
||||||
|
recs.append(("🔥", f"**떡상 패턴 복제** — `{winner['title'][:40]}` ({_fmt_num(winner['views'])}회). 같은 후크/포맷으로 후속편."))
|
||||||
|
if weekday_avg and len(weekday_avg) >= 3:
|
||||||
|
best_day = max(weekday_avg.items(), key=lambda x: x[1])[0]
|
||||||
|
recs.append(("📅", f"**발행 요일 최적화** — {best_day}요일 영상이 평균 가장 잘 됨. 다음 업로드 {best_day}요일 추천."))
|
||||||
|
if avg_like_rate < 2.0 and avg_views > 100:
|
||||||
|
recs.append(("👍", f"**좋아요율 개선** — 현재 {avg_like_rate:.2f}% (업계 2~5%). 영상 끝 콜아웃 강화."))
|
||||||
|
if avg_comment_rate < 0.3 and avg_views > 100:
|
||||||
|
recs.append(("💬", f"**댓글 유도 강화** — 현재 {avg_comment_rate:.2f}% (업계 0.3~1%). 영상 중간 시청자 의견 질문 삽입."))
|
||||||
|
if top_keywords:
|
||||||
|
recs.append(("🔑", f"**제목 키워드 활용** — 상위 영상의 `{', '.join(top_keywords[:3])}` 키워드를 다음 제목에 통합."))
|
||||||
|
if shorts_count == 0 and len(rows) >= 5:
|
||||||
|
recs.append(("📱", f"**Shorts 시도** — 최근 {lookback}일에 Shorts 0개. 신규 유입 채널로 좋음."))
|
||||||
|
if pace < 2:
|
||||||
|
recs.append(("⏰", f"**업로드 빈도 점검** — 월 {pace:.1f}개 페이스. 알고리즘 친화적 페이스는 주 1회+."))
|
||||||
|
if not recs:
|
||||||
|
recs.append(("ℹ️", "데이터 부족 — 더 많은 영상 업로드 후 재분석 권장"))
|
||||||
|
for i, (icon, rec) in enumerate(recs, 1):
|
||||||
|
L.append(f"**{i}. {icon} {rec}**" if i == 1 else f"{i}. {icon} {rec}")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
# 시청자 반응 키워드 (상위 영상 댓글 기반)
|
||||||
|
all_comments = []
|
||||||
|
for cs in comments_by_video.values():
|
||||||
|
all_comments.extend(c["text"] for c in cs)
|
||||||
|
if all_comments:
|
||||||
|
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
L.append("")
|
||||||
|
L.append("## 💬 시청자가 남긴 키워드")
|
||||||
|
L.append("")
|
||||||
|
all_text = " ".join(all_comments)
|
||||||
|
words = re.findall(r'[가-힣]{2,}|[a-zA-Z]{3,}', all_text)
|
||||||
|
# URL 조각·도메인은 의미 없으니 제외
|
||||||
|
url_noise = {'https', 'http', 'youtu', 'www', 'com'}
|
||||||
|
words = [w for w in words if w.lower() not in stop_en and w not in stop_kr and w.lower() not in url_noise and not re.match(r'^[a-zA-Z0-9_]{8,}$', w)]
|
||||||
|
word_freq = Counter(words).most_common(8)
|
||||||
|
if word_freq:
|
||||||
|
kw_line = ' · '.join(f"`{w}`({c})" for w, c in word_freq)
|
||||||
|
L.append(kw_line)
|
||||||
|
L.append("")
|
||||||
|
L.append("> 시청자 머릿속에 남은 단어. 다음 영상 제목·썸네일·후크에 활용.")
|
||||||
|
L.append("")
|
||||||
|
|
||||||
|
summary = chr(10).join(L)
|
||||||
|
# v2.89.49 — stdout은 보고서 markdown만. 메타·진단 메시지는 stderr로.
|
||||||
|
print(summary)
|
||||||
|
with open(REPORT, "a", encoding="utf-8") as f:
|
||||||
|
f.write(chr(10) + chr(10) + summary + chr(10) + chr(10) + "---" + chr(10))
|
||||||
|
print(f"\n✅ 보고서 저장: {REPORT}", file=sys.stderr)
|
||||||
|
# Telegram (4096자 제한 — plain text라 마크다운 특수문자 그대로 보내도 통과)
|
||||||
|
tg_lines = []
|
||||||
|
tg_lines.append(f"📊 {ch_title} — 채널 분석")
|
||||||
|
tg_lines.append(f"({time.strftime('%Y-%m-%d %H:%M')} · 최근 {lookback}일 · 영상 {len(rows)}개)")
|
||||||
|
tg_lines.append("")
|
||||||
|
tg_lines.append(f"구독자 {sub_str} · 누적 {_fmt_num(view_count_total)} · 총 {video_count_total}개")
|
||||||
|
if rows:
|
||||||
|
tg_lines.append(f"중간값 {_fmt_num(median_views)}회 · 최고 {_fmt_num(rows_sorted[0]['views'])} · 최저 {_fmt_num(rows_sorted[-1]['views'])}")
|
||||||
|
tg_lines.append(f"좋아요율 {avg_like_rate:.2f}% · 댓글율 {avg_comment_rate:.2f}%")
|
||||||
|
tg_lines.append("")
|
||||||
|
if top_videos:
|
||||||
|
tg_lines.append(f"🏆 최고: {_fmt_num(top_videos[0]['views'])} {top_videos[0]['title'][:40]}")
|
||||||
|
if bottom_videos:
|
||||||
|
tg_lines.append(f"🥶 부진: {_fmt_num(bottom_videos[0]['views'])} {bottom_videos[0]['title'][:40]}")
|
||||||
|
tg_lines.append("")
|
||||||
|
if recs:
|
||||||
|
tg_lines.append("🎯 액션:")
|
||||||
|
for i, (icon, rec) in enumerate(recs[:3], 1):
|
||||||
|
# 마크다운 ** 제거하고 plain text로
|
||||||
|
clean = re.sub(r'\*\*|`', '', rec.split(' — ')[0] if ' — ' in rec else rec)
|
||||||
|
tg_lines.append(f"{i}. {icon} {clean[:80]}")
|
||||||
|
tg_lines.append("")
|
||||||
|
tg_lines.append("(전체 분석은 IDE 채팅창 확인)")
|
||||||
|
tg_text = chr(10).join(tg_lines)
|
||||||
|
_push_telegram(acct, tg_text)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 📨 텔레그램 보고
|
||||||
|
|
||||||
|
다른 도구가 보고를 메신저로 보낼 때 호출하는 통신선. ▶ 실행하면 **연결 테스트** — 받으면 OK, 안 오면 토큰/chat_id 다시 확인.
|
||||||
|
|
||||||
|
## 토큰은 어디에 넣나요? — **Secretary 비서가 정답**
|
||||||
|
|
||||||
|
회사 아키텍처상 비서(Secretary) 에이전트가 메신저 담당이에요. 거기 한 번만 넣으면 모든 에이전트가 공유합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
_agents/secretary/config.md
|
||||||
|
```
|
||||||
|
|
||||||
|
이 파일에 다음 두 줄:
|
||||||
|
```
|
||||||
|
- TELEGRAM_BOT_TOKEN: <토큰>
|
||||||
|
- TELEGRAM_CHAT_ID: <chat_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
(이 파일은 `.gitignore`에 의해 git에 안 올라갑니다.)
|
||||||
|
|
||||||
|
### 구버전 호환 (선택)
|
||||||
|
이전 버전에서 `youtube_account.json`에 텔레그램 입력하셨다면 그것도 fallback으로 동작합니다 — 다만 비서 쪽이 우선이고 캐노니컬이에요.
|
||||||
|
|
||||||
|
## 어떻게 도와주나요?
|
||||||
|
- ✅ 연결 확인 핑 (인자 없이 실행)
|
||||||
|
- 📨 모든 에이전트(YouTube, Secretary 등)가 자동 보고 보내는 채널
|
||||||
|
- 🔕 토큰/chat_id 미설정이면 다른 도구는 텔레그램 단계만 건너뜁니다
|
||||||
|
|
||||||
|
## 봇 만드는 법 (한 번만)
|
||||||
|
1. 텔레그램 [@BotFather](https://t.me/BotFather) → `/newbot` → 토큰 받음
|
||||||
|
2. 봇에게 `/start` 등 메시지 1회 보내기
|
||||||
|
3. `https://api.telegram.org/bot<TOKEN>/getUpdates` 열어 `chat.id` 확인
|
||||||
|
4. `_agents/secretary/config.md`의 `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`에 입력
|
||||||
|
5. 이 도구 [▶ 실행] → 핑 메시지 도착하면 완료
|
||||||
|
|
||||||
|
## 다른 도구에서 어떻게 쓰이나?
|
||||||
|
- "내 영상 체크" → 떡상/부진 요약 푸시
|
||||||
|
- "경쟁 채널 분석" → 다음 액션 브리프 푸시
|
||||||
|
- 비서의 전사 데일리 브리핑도 같은 라인 사용
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Telegram Notify — small wrapper that sends a message to your Telegram bot.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
1. No CLI arg → sends a connectivity test ("✅ 텔레그램 연결 정상").
|
||||||
|
2. With CLI arg(s) → sends those as the message body. Other tools can call
|
||||||
|
this script to push their summaries.
|
||||||
|
|
||||||
|
telegram_v3 — Secretary's tools/telegram_setup.json is the canonical
|
||||||
|
UI-managed home (input via Skills ⚙️). Falls back to legacy config.md
|
||||||
|
and finally to youtube_account.json so older setups keep working."""
|
||||||
|
import os, json, sys, time, re
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ACCOUNT = os.path.join(HERE, "youtube_account.json")
|
||||||
|
# tools/ → youtube/ → _agents/ → brain root
|
||||||
|
BRAIN_ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
||||||
|
SECRETARY_TOOL_JSON = os.path.join(BRAIN_ROOT, "_agents", "secretary", "tools", "telegram_setup.json")
|
||||||
|
SECRETARY_CFG = os.path.join(BRAIN_ROOT, "_agents", "secretary", "config.md")
|
||||||
|
|
||||||
|
def _resolve_telegram():
|
||||||
|
"""Secretary tool JSON > Secretary legacy md > youtube_account.json."""
|
||||||
|
token, chat = "", ""
|
||||||
|
if os.path.exists(SECRETARY_TOOL_JSON):
|
||||||
|
try:
|
||||||
|
with open(SECRETARY_TOOL_JSON, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
token = (cfg.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
chat = (cfg.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if (not token or not chat) and os.path.exists(SECRETARY_CFG):
|
||||||
|
try:
|
||||||
|
with open(SECRETARY_CFG, "r", encoding="utf-8") as f:
|
||||||
|
txt = f.read()
|
||||||
|
if not token:
|
||||||
|
m = re.search(r"TELEGRAM_BOT_TOKEN\s*[::=]\s*([A-Za-z0-9:_\-]+)", txt)
|
||||||
|
if m: token = m.group(1).strip()
|
||||||
|
if not chat:
|
||||||
|
m = re.search(r"TELEGRAM_CHAT_ID\s*[::=]\s*(-?\d+)", txt)
|
||||||
|
if m: chat = m.group(1).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if (not token or not chat) and os.path.exists(ACCOUNT):
|
||||||
|
try:
|
||||||
|
with open(ACCOUNT, "r", encoding="utf-8") as f:
|
||||||
|
acct = json.load(f)
|
||||||
|
if not token: token = (acct.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
if not chat: chat = (acct.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return token, chat
|
||||||
|
|
||||||
|
def main():
|
||||||
|
token, chat = _resolve_telegram()
|
||||||
|
if not token or not chat:
|
||||||
|
print("❌ TELEGRAM_BOT_TOKEN 또는 TELEGRAM_CHAT_ID를 못 찾았어요.")
|
||||||
|
print(" 권장: 비서(Secretary) 클릭 → Skills → 📨 텔레그램 연결 ⚙️ → 폼에 입력")
|
||||||
|
print(" 봇 만들기: Telegram → @BotFather → /newbot")
|
||||||
|
print(" chat_id: 봇에 메시지 1회 → https://api.telegram.org/bot<TOKEN>/getUpdates 에서 chat.id 확인")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
body = " ".join(sys.argv[1:])
|
||||||
|
else:
|
||||||
|
body = f"✅ 텔레그램 연결 정상 — {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n비서(Secretary) 또는 YouTube 도구가 이 채널로 보고를 보낼 수 있습니다."
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pip install requests")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
json={"chat_id": chat, "text": body, "parse_mode": "Markdown"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(f"✅ 전송 OK ({len(body)}자)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 전송 실패: {e}")
|
||||||
|
if "Bad Request" in str(e):
|
||||||
|
print(" chat_id가 정확한지, 봇과 한 번이라도 대화를 시작했는지 확인하세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 🎯 트렌드 스나이퍼
|
||||||
|
|
||||||
|
유튜브 Data API로 최근 30일 떡상 영상을 수집하고, 로컬 LLM(Ollama/LM Studio)으로 패턴을 분석해 다음 영상 기획안(제목·썸네일·후크)을 도출합니다.
|
||||||
|
|
||||||
|
## 필요한 것
|
||||||
|
- Python 3 + `pip install google-api-python-client requests`
|
||||||
|
- `youtube_account.json`에 `YOUTUBE_API_KEY` 채우기 (한 번만)
|
||||||
|
- 로컬 LLM (Ollama 또는 LM Studio)이 켜져 있어야 함
|
||||||
|
|
||||||
|
## 설정값 (trend_sniper.json)
|
||||||
|
- `TARGET_KEYWORDS` — 분석할 키워드 배열
|
||||||
|
- (API 키·Ollama URL·모델은 공유 `youtube_account.json`에서 자동 로드)
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
패널의 [▶ 실행] 버튼을 누르거나 터미널에서:
|
||||||
|
```bash
|
||||||
|
python trend_sniper.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
같은 폴더에 `trend_sniper_report.md` 누적 저장.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trend Sniper — pulls top YouTube videos for target keywords, asks a local
|
||||||
|
LLM (Ollama/LM Studio) to extract the algorithmic patterns, and writes a
|
||||||
|
planning report next to this script.
|
||||||
|
|
||||||
|
Shared keys (API key, OLLAMA_URL, MODEL) come from youtube_account.json so
|
||||||
|
you only set them once. Per-tool keys (TARGET_KEYWORDS) come from
|
||||||
|
trend_sniper.json. If a key exists in both, trend_sniper.json wins.
|
||||||
|
|
||||||
|
Requires: pip install google-api-python-client requests
|
||||||
|
"""
|
||||||
|
import os, json, time, random, datetime, sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CONFIG_PATH = os.path.join(HERE, "trend_sniper.json")
|
||||||
|
ACCOUNT_PATH = os.path.join(HERE, "youtube_account.json")
|
||||||
|
REPORT_PATH = os.path.join(HERE, "trend_sniper_report.md")
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 설정 파일을 읽을 수 없어요: {CONFIG_PATH}\n{e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def load_account():
|
||||||
|
try:
|
||||||
|
if os.path.exists(ACCOUNT_PATH):
|
||||||
|
with open(ACCOUNT_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _shared(cfg, acct, key, default=""):
|
||||||
|
"""Per-tool config wins; falls back to shared account; finally default."""
|
||||||
|
v = cfg.get(key)
|
||||||
|
if v not in (None, "", []):
|
||||||
|
return v
|
||||||
|
v = acct.get(key)
|
||||||
|
if v not in (None, "", []):
|
||||||
|
return v
|
||||||
|
return default
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = load_config()
|
||||||
|
acct = load_account()
|
||||||
|
api_key = (_shared(cfg, acct, "YOUTUBE_API_KEY") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
print("⚠️ YOUTUBE_API_KEY가 비어있어요. youtube_account.json 또는 trend_sniper.json에 입력하세요.")
|
||||||
|
print(" 발급: https://console.cloud.google.com/ → YouTube Data API v3 사용 설정 → 사용자 인증 정보 → API 키")
|
||||||
|
sys.exit(1)
|
||||||
|
target_keywords = cfg.get("TARGET_KEYWORDS", [])
|
||||||
|
if not target_keywords:
|
||||||
|
print("⚠️ TARGET_KEYWORDS가 비어있어요. 분석할 키워드를 1개 이상 추가하세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
ollama_url = (_shared(cfg, acct, "OLLAMA_URL", "http://127.0.0.1:11434") or "http://127.0.0.1:11434").rstrip("/")
|
||||||
|
model = _shared(cfg, acct, "MODEL", "") or ""
|
||||||
|
pick = min(2, len(target_keywords))
|
||||||
|
chosen = random.sample(target_keywords, pick)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
except ImportError:
|
||||||
|
print("❌ google-api-python-client가 설치되지 않았어요.")
|
||||||
|
print(" 설치: pip install google-api-python-client requests")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("❌ requests가 설치되지 않았어요. pip install requests")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n🎯 [트렌드 스나이퍼] 키워드 {chosen} 스캔 시작...")
|
||||||
|
youtube = build('youtube', 'v3', developerKey=api_key)
|
||||||
|
last_month = (datetime.datetime.utcnow() - datetime.timedelta(days=30)).isoformat("T") + "Z"
|
||||||
|
sniper_data = []
|
||||||
|
for q in chosen:
|
||||||
|
print(f"📡 [{q}] 검색 중...")
|
||||||
|
try:
|
||||||
|
req = youtube.search().list(
|
||||||
|
part="snippet", q=q, maxResults=5, order="viewCount",
|
||||||
|
publishedAfter=last_month, type="video"
|
||||||
|
)
|
||||||
|
res = req.execute()
|
||||||
|
for item in res.get('items', []):
|
||||||
|
title = item['snippet']['title']
|
||||||
|
channel = item['snippet']['channelTitle']
|
||||||
|
sniper_data.append(f"[{q}] 채널: {channel} | 제목: {title}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 검색 오류 ({q}): {e}")
|
||||||
|
|
||||||
|
if not sniper_data:
|
||||||
|
print("❌ 수집된 데이터 없음. API 키 한도/네트워크 확인.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data_text = "\n".join(sniper_data)
|
||||||
|
prompt = f"""당신은 유튜브 알고리즘 마스터마인드입니다. 아래는 최근 30일 떡상 영상입니다.
|
||||||
|
|
||||||
|
[키워드] {', '.join(chosen)}
|
||||||
|
[데이터]
|
||||||
|
{data_text}
|
||||||
|
|
||||||
|
분석해서 마크다운 보고서를 작성하세요. 반드시 3섹션:
|
||||||
|
1. 🌍 트렌드 해킹 분석 — 어떤 패턴이 조회수를 끌고 있는지
|
||||||
|
2. 🎯 빈집 털기 전략 — 차별화 가능한 틈새 주제
|
||||||
|
3. 🎬 파괴적 영상 기획안 — 썸네일 카피, 제목 3개, 후킹 오프닝(첫 5초)
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("🧠 [LLM 분석 중...]")
|
||||||
|
if not model:
|
||||||
|
# Try first available model
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{ollama_url}/api/tags", timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
models = [m["name"] for m in r.json().get("models", [])]
|
||||||
|
if not models:
|
||||||
|
print("❌ 로컬 LLM에 설치된 모델이 없어요. Ollama/LM Studio에서 모델을 풀(pull)하세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
model = models[0]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 로컬 LLM 연결 실패 ({ollama_url}): {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"{ollama_url}/api/generate",
|
||||||
|
json={"model": model, "prompt": prompt, "stream": False},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
report = r.json().get("response", "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ LLM 호출 실패: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(report)
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
with open(REPORT_PATH, "a", encoding="utf-8") as f:
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"\n\n# 🎯 트렌드 스나이핑 보고서 — {now}\n")
|
||||||
|
f.write(f"## 📡 키워드: {', '.join(chosen)}\n\n")
|
||||||
|
f.write(report)
|
||||||
|
f.write("\n\n---\n")
|
||||||
|
print(f"\n✅ 보고서 저장: {REPORT_PATH}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 🔑 계정 / 채널 (공유 설정)
|
||||||
|
|
||||||
|
여기 한 번만 채워두면 다른 모든 YouTube 도구(트렌드 스나이퍼·내 영상 체크·댓글 수집기·경쟁 채널 분석·텔레그램 보고)가 이 값을 그대로 가져다 씁니다. 매번 도구마다 같은 키를 넣지 않아도 돼요.
|
||||||
|
|
||||||
|
## 채워야 하는 항목
|
||||||
|
|
||||||
|
| 키 | 설명 | 채우는 법 |
|
||||||
|
|---|---|---|
|
||||||
|
| `YOUTUBE_API_KEY` | YouTube Data API v3 키 | [console.cloud.google.com](https://console.cloud.google.com/) → 프로젝트 → "YouTube Data API v3" 사용 설정 → 사용자 인증 정보 → API 키. 무료 한도 충분(하루 10,000 단위). |
|
||||||
|
| `MY_CHANNEL_HANDLE` | 본인 채널 @핸들 | 예: `@mychannel`. 핸들 또는 ID 둘 중 하나만 채우면 됨. |
|
||||||
|
| `MY_CHANNEL_ID` | 본인 채널 ID (UCxxxx) | 핸들로 못 잡힐 때 백업용. studio.youtube.com → 설정 → 채널에서 확인. |
|
||||||
|
| `WATCHED_CHANNELS` | 댓글 수집 대상 채널 핸들 목록 | 예: `["@channel_a", "@channel_b"]`. 댓글 수집기가 이 채널들 최근 영상의 댓글을 메모리로 가져옵니다. |
|
||||||
|
| `COMPETITOR_CHANNELS` | 경쟁 채널 분석 대상 | 같은 형식. 경쟁 채널 분석 도구가 패턴을 뽑아 다음 액션을 추천합니다. |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | (선택) 봇 토큰 | **권장: 비서(Secretary) 에이전트의 `_agents/secretary/config.md`에 입력하세요.** 거기 넣으면 모든 에이전트가 공유. 여기 입력해도 동작은 하지만 fallback일 뿐. |
|
||||||
|
| `TELEGRAM_CHAT_ID` | (선택) chat_id | 위와 같음 — Secretary가 우선. |
|
||||||
|
| `OLLAMA_URL` | 로컬 LLM 주소 | 기본 `http://127.0.0.1:11434`. LM Studio면 보통 `http://127.0.0.1:1234`. |
|
||||||
|
| `MODEL` | 분석에 쓸 모델 이름 | 비워두면 첫 번째로 발견된 모델을 자동 선택. |
|
||||||
|
|
||||||
|
## 실행하면?
|
||||||
|
입력값이 제대로 들어왔는지 확인 리포트만 출력합니다 (실제 데이터 호출 X). 키가 비어있으면 알려줍니다.
|
||||||
|
|
||||||
|
## 텔레그램은 따로 — 비서(Secretary)에 입력
|
||||||
|
텔레그램 토큰은 비서 담당이에요. `_agents/secretary/config.md`에 `TELEGRAM_BOT_TOKEN: <토큰>` 한 줄 + `TELEGRAM_CHAT_ID: <id>` 한 줄 넣으면 **모든 에이전트가 공유**합니다 (YouTube 도구 포함). 여기 youtube_account.json에 같이 넣어도 동작하지만 비서 쪽이 우선이에요.
|
||||||
|
|
||||||
|
## 어디 저장되나?
|
||||||
|
`youtube_account.json`은 `.gitignore`에 의해 git에 안 올라갑니다 (API 키·토큰 보호).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""YouTube Account / Channels — shared config for every YouTube tool.
|
||||||
|
|
||||||
|
This script doesn't fetch anything by itself. It's listed in the agent panel
|
||||||
|
so you can click ⚙️ once and fill in your API key, channel, watched
|
||||||
|
channels, etc. — and every other tool will read from here.
|
||||||
|
|
||||||
|
Running it just prints a sanity-check report so you can confirm the values
|
||||||
|
are loaded correctly (without leaking the full API key)."""
|
||||||
|
import os, json, sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CONFIG_PATH = os.path.join(HERE, "youtube_account.json")
|
||||||
|
|
||||||
|
def load():
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = load()
|
||||||
|
api = (cfg.get("YOUTUBE_API_KEY") or "").strip()
|
||||||
|
masked = (api[:4] + "…" + api[-3:]) if len(api) >= 8 else ("(빈 값)" if not api else "(짧음)")
|
||||||
|
print("─── YouTube 계정 / 채널 설정 ───")
|
||||||
|
print(f" API 키 : {masked}")
|
||||||
|
print(f" 내 채널 핸들 : {cfg.get('MY_CHANNEL_HANDLE') or '(없음)'}")
|
||||||
|
print(f" 내 채널 ID : {cfg.get('MY_CHANNEL_ID') or '(없음)'}")
|
||||||
|
watched = cfg.get('WATCHED_CHANNELS') or []
|
||||||
|
print(f" 감시 채널 ({len(watched)}개) : {', '.join(watched) if watched else '(없음)'}")
|
||||||
|
competitors = cfg.get('COMPETITOR_CHANNELS') or []
|
||||||
|
print(f" 경쟁 채널 ({len(competitors)}개): {', '.join(competitors) if competitors else '(없음)'}")
|
||||||
|
tg_bot = (cfg.get('TELEGRAM_BOT_TOKEN') or '').strip()
|
||||||
|
tg_chat = (cfg.get('TELEGRAM_CHAT_ID') or '').strip()
|
||||||
|
if tg_bot and tg_chat:
|
||||||
|
print(f" 텔레그램 : 연결됨 (chat {tg_chat})")
|
||||||
|
else:
|
||||||
|
print(f" 텔레그램 : 미설정 (보고 알림 비활성)")
|
||||||
|
print(f" Ollama URL : {cfg.get('OLLAMA_URL') or 'http://127.0.0.1:11434'}")
|
||||||
|
print(f" 분석 모델 : {cfg.get('MODEL') or '(자동 선택)'}")
|
||||||
|
if not api:
|
||||||
|
print("\n⚠️ API 키가 비어있어요. 다른 도구들이 동작하지 않습니다.")
|
||||||
|
print(" 발급: https://console.cloud.google.com/ → YouTube Data API v3")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\n✅ 공유 설정 로드 OK. 다른 도구들이 이 값을 자동으로 사용합니다.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 🧬 1인 기업 OS — 자가 매뉴얼
|
||||||
|
|
||||||
|
## 이 폴더는 무엇인가요?
|
||||||
|
당신의 1인 기업의 두뇌입니다. 7명의 AI 에이전트가 여기서 일합니다.
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
- `_shared/` — 모든 에이전트가 매번 읽는 공동 메모리
|
||||||
|
- `identity.md` — 회사 정체성 (이름, 톤, 가치)
|
||||||
|
- `goals.md` — 목표
|
||||||
|
- `decisions.md` — 의사결정 로그 (자가학습이 자동 누적)
|
||||||
|
- `_system.md` — 이 파일
|
||||||
|
- `_agents/<id>/` — 각 에이전트 개인 공간
|
||||||
|
- `memory.md` — 자가학습 (자동, append-only)
|
||||||
|
- `prompt.md` — 페르소나 디테일 (사용자가 편집)
|
||||||
|
- `config.md` — API 키·시크릿 (`.gitignore`로 보호)
|
||||||
|
- `sessions/<ts>/` — 세션별 산출물 (자동)
|
||||||
|
- `_cache/` — API 응답 캐시 (sync 제외)
|
||||||
|
|
||||||
|
## 메모리 위계 (충돌 시 우선순위)
|
||||||
|
1. `decisions.md` — 가장 강한 신뢰
|
||||||
|
2. `identity.md`
|
||||||
|
3. `goals.md`
|
||||||
|
4. 개인 메모리
|
||||||
|
5. 지식 베이스 (`10_Wiki/`)
|
||||||
|
|
||||||
|
## 다른 PC로 옮길 때
|
||||||
|
1. 새 PC에 Connect AI 설치
|
||||||
|
2. 👔 모드 ON → "📥 다른 PC에서 가져오기" 선택
|
||||||
|
3. GitHub URL 입력 → 자동 clone
|
||||||
|
4. 끝.
|
||||||
|
|
||||||
|
## 동기화 정책
|
||||||
|
- `_shared/`, `_agents/*/memory.md`, `_agents/*/prompt.md`, `sessions/` → git sync ✅
|
||||||
|
- `_agents/*/config.md`, `_cache/` → git sync ❌ (시크릿·캐시)
|
||||||
|
|
||||||
|
## 7명의 에이전트
|
||||||
|
- 🧭 **CEO** (Chief Executive Agent): 오케스트레이션, 작업 분해, 종합 판단, 다음 액션 결정
|
||||||
|
- 📺 **YouTube** (Head of YouTube): 유튜브 채널 운영, 영상 기획서(제목·후크·구조), 트렌드 분석, 썸네일 브리프, 업로드 메타데이터, 시청자 유지율 전략
|
||||||
|
- 📷 **Instagram** (Head of Instagram): 인스타그램 릴스/피드 콘셉트, 캡션, 해시태그 전략, 게시 시간, 스토리, 팔로워 인게이지먼트
|
||||||
|
- 🎨 **Designer** (Lead Designer): 브랜드 디자인 브리프(컬러·타이포·레퍼런스), 썸네일 컨셉 3안, 비주얼 시스템, 디자인 가이드
|
||||||
|
- 💻 **Developer** (Lead Engineer): 코드, 자동화 스크립트, API 통합, 웹사이트/봇, 데이터 파이프라인, 디버깅
|
||||||
|
- 💰 **Business** (Head of Business): 수익화 모델, 가격 전략, 시장·경쟁 분석, ROI/KPI 설계, 비즈니스 의사결정
|
||||||
|
- 📱 **Secretary** (Personal Assistant): 일정·할 일 관리, 다른 에이전트 작업 요약·텔레그램 보고, 데일리 브리핑, 알림
|
||||||
|
- ✂️ **Editor** (Video & Content Editor): 영상 편집 디렉션, 컷 구성, B-roll 제안, 자막·타이틀, 스크립트 다듬기, 콘텐츠 폴리싱
|
||||||
|
- ✍️ **Writer** (Copywriter): 카피라이팅, 영상 스크립트 초안, 인스타 캡션, 블로그 글, 메일 톤앤매너, 후크 작성
|
||||||
|
- 🔍 **Researcher** (Trend & Data Researcher): 트렌드 리서치, 경쟁사 분석, 데이터 수집·요약, 인용 자료 정리, 사실 확인
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 🎯 공동 목표
|
||||||
|
|
||||||
|
## 올해 핵심 목표
|
||||||
|
- [ ] 게임 개발 1개
|
||||||
|
|
||||||
|
## 1개월 내 단기 목표
|
||||||
|
- _자가학습이 채울 예정_
|
||||||
|
|
||||||
|
## 지금 가장 필요한 것
|
||||||
|
- 30-40대 어른들도 즐겁게 즐길 수 있는 게임을 개발하고 싶어.
|
||||||
|
|
||||||
|
> 모든 에이전트가 매번 이 파일을 읽고 일합니다. 회사 설정 모달에서 폼으로도 수정 가능.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 🏢 회사 정체성
|
||||||
|
|
||||||
|
- **회사 이름:** g1nation
|
||||||
|
- **한 줄 소개:** PM
|
||||||
|
- **타깃 청중:** 30대~50대 사회인
|
||||||
|
- **브랜드 톤:** _자가학습이 채울 예정_
|
||||||
|
- **금기:** _자가학습이 채울 예정_
|
||||||
|
|
||||||
|
> 이 파일은 사용자가 직접 편집하거나, 작업하면서 자가학습으로 채워집니다.
|
||||||
|
> 채팅 사이드바의 "👔 회사명" 뱃지를 누르면 폼으로 수정할 수도 있어요.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tasksCompleted": 0,
|
||||||
|
"knowledgeInjected": 0,
|
||||||
|
"lastSessionDate": "",
|
||||||
|
"foundedAt": "2026-05-07"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user