feat: upstream v3.2.1 기반으로 업그레이드 + eli 블로그 커스터마이징
- upstream sinmb79/blog-writer v3.2.1 코드 베이스 적용 - config_resolver, CLI, writer_bot, shorts pipeline 등 신규 기능 포함 - load_dotenv Windows 경로 → Docker 호환 load_dotenv() 변경 (25개 파일) - runtime_guard.py Docker 환경 bypass 추가 - config/blogs.json: eli-ai 블로그 정체성 (8개 카테고리) - config/sources.json: 38개 RSS 소스 유지 - config/engine.json: writing provider → gemini (2.5-flash) - config/safety_keywords.json: 모든 글 수동 승인 (score 101) - bots/scheduler.py: 시스템 프롬프트 eli 블로그 기준으로 업데이트 - bots/publisher_bot.py: .env refresh token OAuth 폴백 로직 추가 - requirements.txt: google-generativeai, groq 활성화 - Dockerfile + docker-compose.yml: NAS Docker 배포 설정 - CLAUDE.md: 프로젝트 메타데이터 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -73,6 +73,13 @@ logs/
|
|||||||
# ─── Node.js (대시보드 프론트엔드) ───────────────────────────
|
# ─── Node.js (대시보드 프론트엔드) ───────────────────────────
|
||||||
dashboard/frontend/node_modules/
|
dashboard/frontend/node_modules/
|
||||||
|
|
||||||
|
# ─── Dashboard 빌드 결과물 (dist는 포함) ──────────────────────
|
||||||
|
!dashboard/frontend/dist
|
||||||
|
!dashboard/frontend/dist/**
|
||||||
|
|
||||||
|
# ─── OS ──────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# ─── IDE ──────────────────────────────────────────────────────
|
# ─── IDE ──────────────────────────────────────────────────────
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# blog-writer
|
||||||
|
|
||||||
|
이 파일은 Claude Code가 어느 경로에서 실행되든 자동으로 로드합니다.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 저장소
|
||||||
|
- Git 서버: Gitea (자체 NAS 운영)
|
||||||
|
- Gitea URL: http://nas.gru.farm:3001
|
||||||
|
- 계정: airkjw
|
||||||
|
- 저장소: blog-writer
|
||||||
|
- Remote: http://nas.gru.farm:3001/airkjw/blog-writer
|
||||||
|
- 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963
|
||||||
|
|
||||||
|
## NAS ssh 공개키
|
||||||
|
- 아이디: airkjw
|
||||||
|
- 공개키: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICkbFPXF3CHi91UsWIrIsjG8srqceVm1wKrL3K1doM1V
|
||||||
|
- 주소: nas.gru.farm:22
|
||||||
|
- 내부 IP: 192.168.0.17
|
||||||
|
- Docker 명령: sudo /usr/local/bin/docker (NOPASSWD)
|
||||||
|
- Docker Compose: sudo /usr/local/bin/docker compose
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc libxml2-dev libxslt-dev libffi-dev libssl-dev \
|
||||||
|
fonts-nanum ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
RUN mkdir -p data/outputs data/topics data/collected data/pending_review \
|
||||||
|
data/published data/discarded data/drafts data/originals \
|
||||||
|
data/images data/analytics logs assets/fonts
|
||||||
|
|
||||||
|
CMD ["python3", "bots/scheduler.py"]
|
||||||
172
assets/blogger-custom.css
Normal file
172
assets/blogger-custom.css
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/* ── eli blog 커스텀 CSS ── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ai: #7C3AED;
|
||||||
|
--travel: #EA580C;
|
||||||
|
--startup: #2563EB;
|
||||||
|
--review: #0891B2;
|
||||||
|
--tips: #16A34A;
|
||||||
|
--app: #DB2777;
|
||||||
|
--finance: #CA8A04;
|
||||||
|
--fact: #DC2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, .body-fauxcolumn-outer {
|
||||||
|
font-family: 'Noto Sans KR', sans-serif !important;
|
||||||
|
background: #F4F6F9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.header-bar, .header-outer, #header {
|
||||||
|
background: #fff !important;
|
||||||
|
border-bottom: 1px solid #E5E7EB !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.06) !important;
|
||||||
|
}
|
||||||
|
.header-inner h1, .header-inner h1 a,
|
||||||
|
.Header h1, .Header h1 a {
|
||||||
|
font-family: 'Noto Sans KR', sans-serif !important;
|
||||||
|
font-size: 22px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #1A1A1A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 포스트 카드 */
|
||||||
|
.post-outer, .post {
|
||||||
|
background: #fff !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.07) !important;
|
||||||
|
margin-bottom: 24px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
transition: transform .2s, box-shadow .2s !important;
|
||||||
|
}
|
||||||
|
.post-outer:hover, .post:hover {
|
||||||
|
transform: translateY(-3px) !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 포스트 제목 */
|
||||||
|
.post-title a, h3.post-title a {
|
||||||
|
font-size: 18px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #1A1A1A !important;
|
||||||
|
line-height: 1.45 !important;
|
||||||
|
}
|
||||||
|
.post-title a:hover { color: var(--ai) !important; }
|
||||||
|
|
||||||
|
/* 포스트 본문 */
|
||||||
|
.post-body {
|
||||||
|
font-size: 16px !important;
|
||||||
|
line-height: 1.85 !important;
|
||||||
|
color: #374151 !important;
|
||||||
|
}
|
||||||
|
.post-body h2 { font-size: 20px !important; margin: 28px 0 12px !important; font-weight: 700 !important; }
|
||||||
|
.post-body h3 { font-size: 17px !important; margin: 20px 0 10px !important; font-weight: 700 !important; }
|
||||||
|
.post-body p { margin-bottom: 14px !important; }
|
||||||
|
.post-body ul, .post-body ol { margin: 12px 0 12px 22px !important; }
|
||||||
|
.post-body blockquote {
|
||||||
|
border-left: 4px solid var(--ai) !important;
|
||||||
|
background: #f5f3ff !important;
|
||||||
|
padding: 12px 18px !important;
|
||||||
|
border-radius: 0 8px 8px 0 !important;
|
||||||
|
margin: 16px 0 !important;
|
||||||
|
color: #4B5563 !important;
|
||||||
|
}
|
||||||
|
.post-body a { color: var(--ai) !important; }
|
||||||
|
.post-body code {
|
||||||
|
background: #f3f0ff !important;
|
||||||
|
color: var(--ai) !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
.post-body pre {
|
||||||
|
background: #1e1e2e !important;
|
||||||
|
color: #cdd6f4 !important;
|
||||||
|
padding: 20px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
}
|
||||||
|
.post-body img {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
.post-body table { width: 100% !important; border-collapse: collapse !important; }
|
||||||
|
.post-body th, .post-body td {
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
border: 1px solid #E5E7EB !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
.post-body th { background: #f9fafb !important; font-weight: 700 !important; }
|
||||||
|
|
||||||
|
/* 라벨(카테고리) 배지 */
|
||||||
|
.label-size-1, .label-size-2, .label-size-3,
|
||||||
|
.label-size-4, .label-size-5,
|
||||||
|
.post-labels a, .widget.Label li a {
|
||||||
|
display: inline-block !important;
|
||||||
|
padding: 3px 10px !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
background: var(--ai) !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
margin-right: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 메타 정보 */
|
||||||
|
.post-footer, .post-header { color: #6B7280 !important; font-size: 12px !important; }
|
||||||
|
|
||||||
|
/* 사이드바 */
|
||||||
|
.sidebar-outer, .sidebar .widget {
|
||||||
|
background: #fff !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.07) !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
.widget-title, h2.title {
|
||||||
|
font-size: 14px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
padding: 16px 20px 12px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-bottom: 2px solid var(--ai) !important;
|
||||||
|
color: #1A1A1A !important;
|
||||||
|
}
|
||||||
|
.sidebar .widget ul { list-style: none !important; padding: 0 !important; margin: 0 !important; }
|
||||||
|
.sidebar .widget ul li { border-bottom: 1px solid #F3F4F6 !important; }
|
||||||
|
.sidebar .widget ul li a {
|
||||||
|
display: block !important;
|
||||||
|
padding: 10px 20px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: #374151 !important;
|
||||||
|
}
|
||||||
|
.sidebar .widget ul li a:hover { color: var(--ai) !important; background: #faf5ff !important; }
|
||||||
|
|
||||||
|
/* 페이지네이션 */
|
||||||
|
.blog-pager, #blog-pager {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 12px !important;
|
||||||
|
margin: 32px 0 !important;
|
||||||
|
}
|
||||||
|
.blog-pager a, #blog-pager a {
|
||||||
|
padding: 8px 20px !important;
|
||||||
|
background: #fff !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #374151 !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.08) !important;
|
||||||
|
transition: all .2s !important;
|
||||||
|
}
|
||||||
|
.blog-pager a:hover, #blog-pager a:hover {
|
||||||
|
background: var(--ai) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.post-title a { font-size: 16px !important; }
|
||||||
|
.post-body { font-size: 15px !important; }
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ from google.oauth2.credentials import Credentials
|
|||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
DATA_DIR = BASE_DIR / 'data'
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from typing import Optional
|
|||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
ASSIST_DIR = BASE_DIR / 'data' / 'assist'
|
ASSIST_DIR = BASE_DIR / 'data' / 'assist'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import requests
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
CONFIG_DIR = BASE_DIR / 'config'
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
|||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
|||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import requests
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from requests_oauthlib import OAuth1
|
from requests_oauthlib import OAuth1
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
LOG_DIR = BASE_DIR / 'logs'
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
CONFIG_PATH = BASE_DIR / 'config' / 'engine.json'
|
CONFIG_PATH = BASE_DIR / 'config' / 'engine.json'
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
|||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
DATA_DIR = BASE_DIR / 'data'
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import requests
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
CONFIG_DIR = BASE_DIR / 'config'
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
# novel/ 폴더 기준으로 BASE_DIR 설정
|
# novel/ 폴더 기준으로 BASE_DIR 설정
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from google.oauth2.credentials import Credentials
|
|||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
CONFIG_DIR = BASE_DIR / 'config'
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
@@ -63,6 +63,7 @@ def load_config(filename: str) -> dict:
|
|||||||
|
|
||||||
def get_google_credentials() -> Credentials:
|
def get_google_credentials() -> Credentials:
|
||||||
creds = None
|
creds = None
|
||||||
|
# 1) token.json 파일 우선
|
||||||
if TOKEN_PATH.exists():
|
if TOKEN_PATH.exists():
|
||||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||||
if not creds or not creds.valid:
|
if not creds or not creds.valid:
|
||||||
@@ -70,6 +71,24 @@ def get_google_credentials() -> Credentials:
|
|||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
with open(TOKEN_PATH, 'w') as f:
|
with open(TOKEN_PATH, 'w') as f:
|
||||||
f.write(creds.to_json())
|
f.write(creds.to_json())
|
||||||
|
# 2) .env의 GOOGLE_REFRESH_TOKEN으로 직접 생성 (Docker 환경 대응)
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
refresh_token = os.getenv('GOOGLE_REFRESH_TOKEN', '')
|
||||||
|
client_id = os.getenv('GOOGLE_CLIENT_ID', '')
|
||||||
|
client_secret = os.getenv('GOOGLE_CLIENT_SECRET', '')
|
||||||
|
if refresh_token and client_id and client_secret:
|
||||||
|
creds = Credentials(
|
||||||
|
token=None,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_uri='https://oauth2.googleapis.com/token',
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
scopes=SCOPES,
|
||||||
|
)
|
||||||
|
creds.refresh(Request())
|
||||||
|
with open(TOKEN_PATH, 'w') as f:
|
||||||
|
f.write(creds.to_json())
|
||||||
|
logger.info("Google 인증 성공 (.env refresh token)")
|
||||||
if not creds or not creds.valid:
|
if not creds or not creds.valid:
|
||||||
raise RuntimeError("Google 인증 실패. scripts/get_token.py 를 먼저 실행하세요.")
|
raise RuntimeError("Google 인증 실패. scripts/get_token.py 를 먼저 실행하세요.")
|
||||||
return creds
|
return creds
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from telegram import Update
|
|||||||
from telegram.ext import Application, MessageHandler, CommandHandler, filters, ContextTypes
|
from telegram.ext import Application, MessageHandler, CommandHandler, filters, ContextTypes
|
||||||
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
|
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from telegram.ext import Application, CommandHandler, MessageHandler, filters, C
|
|||||||
import anthropic
|
import anthropic
|
||||||
import re
|
import re
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
CONFIG_DIR = BASE_DIR / 'config'
|
CONFIG_DIR = BASE_DIR / 'config'
|
||||||
@@ -55,28 +55,25 @@ ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
|
|||||||
_claude_client: anthropic.Anthropic | None = None
|
_claude_client: anthropic.Anthropic | None = None
|
||||||
_conversation_history: dict[int, list] = {}
|
_conversation_history: dict[int, list] = {}
|
||||||
|
|
||||||
CLAUDE_SYSTEM_PROMPT = """당신은 The 4th Path 블로그 자동 수익 엔진의 AI 어시스턴트입니다.
|
CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어시스턴트입니다.
|
||||||
|
블로그 운영자 eli가 Telegram으로 명령하면 도와주는 역할입니다.
|
||||||
|
슬로건: "어렵지 않아요, 그냥 읽어봐요"
|
||||||
|
블로그 주소: eli-ai.blogspot.com
|
||||||
|
|
||||||
이 시스템(v3)은 4계층 구조로 운영됩니다:
|
이 시스템(v3)은 4계층 구조로 운영됩니다:
|
||||||
|
|
||||||
[LAYER 1] AI 콘텐츠 생성: OpenClaw(GPT-5.4)가 원본 마크다운 1개 생성
|
[LAYER 1] AI 콘텐츠 생성: Gemini 2.5-flash가 원본 마크다운 1개 생성
|
||||||
[LAYER 2] 변환 엔진: 원본 → 블로그HTML / 인스타카드 / X스레드 / 뉴스레터 자동 변환
|
[LAYER 2] 변환 엔진: 원본 → 블로그HTML / 인스타카드 / X스레드 / 뉴스레터 자동 변환
|
||||||
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
|
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
|
||||||
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
|
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
|
||||||
|
|
||||||
봇 구성:
|
8개 카테고리: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
|
||||||
- collector_bot: 트렌드/RSS 수집 (07:00)
|
|
||||||
- ai_writer: OpenClaw 글 작성 트리거 (08:00)
|
|
||||||
- blog_converter: 마크다운→HTML (08:30)
|
|
||||||
- card_converter: 인스타 카드 1080×1080 (08:30)
|
|
||||||
- thread_converter: X 스레드 변환 (08:30)
|
|
||||||
- publisher_bot: Blogger 발행 (09:00)
|
|
||||||
- instagram_bot: 인스타 발행 (10:00)
|
|
||||||
- x_bot: X 스레드 게시 (11:00)
|
|
||||||
- analytics_bot: 분석/리포트 (22:00)
|
|
||||||
|
|
||||||
사용 가능한 텔레그램 명령:
|
사용 가능한 텔레그램 명령:
|
||||||
/status — 봇 상태
|
/status — 봇 상태
|
||||||
/topics — 오늘 수집된 글감
|
/topics — 오늘 수집된 글감
|
||||||
|
/collect — 글감 즉시 수집
|
||||||
|
/write [번호] [방향] — 특정 글감으로 글 작성
|
||||||
/pending — 검토 대기 글 목록
|
/pending — 검토 대기 글 목록
|
||||||
/approve [번호] — 글 승인 및 발행
|
/approve [번호] — 글 승인 및 발행
|
||||||
/reject [번호] — 글 거부
|
/reject [번호] — 글 거부
|
||||||
@@ -87,6 +84,7 @@ CLAUDE_SYSTEM_PROMPT = """당신은 The 4th Path 블로그 자동 수익 엔진
|
|||||||
/novel_gen [novel_id] — 에피소드 즉시 생성
|
/novel_gen [novel_id] — 에피소드 즉시 생성
|
||||||
/novel_status — 소설 파이프라인 진행 현황
|
/novel_status — 소설 파이프라인 진행 현황
|
||||||
|
|
||||||
|
모든 글은 발행 전 운영자 승인이 필요합니다.
|
||||||
사용자의 자연어 요청을 이해하고 적절히 안내하거나 답변해주세요.
|
사용자의 자연어 요청을 이해하고 적절히 안내하거나 답변해주세요.
|
||||||
한국어로 간결하게 답변하세요."""
|
한국어로 간결하게 답변하세요."""
|
||||||
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
sys.path.insert(0, str(BASE_DIR))
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
sys.path.insert(0, str(BASE_DIR))
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
{
|
{
|
||||||
"id": "main",
|
"id": "main",
|
||||||
"blog_id": "${BLOG_MAIN_ID}",
|
"blog_id": "${BLOG_MAIN_ID}",
|
||||||
"name": "테크인사이더",
|
"name": "AI? 그게 뭔데?",
|
||||||
"persona": "tech_insider",
|
"persona": "eli",
|
||||||
"domain": "",
|
"domain": "eli-ai.blogspot.com",
|
||||||
|
"tagline": "어렵지 않아요, 그냥 읽어봐요",
|
||||||
"active": true,
|
"active": true,
|
||||||
"phase": 1,
|
"phase": 1,
|
||||||
"labels": ["쉬운세상", "숨은보물", "바이브리포트", "팩트체크", "한컷"]
|
"labels": ["AI인사이트", "여행맛집", "스타트업", "제품리뷰", "생활꿀팁", "앱추천", "재테크절약", "팩트체크"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
||||||
"_updated": "2026-03-29",
|
"_updated": "2026-03-29",
|
||||||
"writing": {
|
"writing": {
|
||||||
"provider": "openclaw",
|
"provider": "gemini",
|
||||||
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
|
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
|
||||||
"options": {
|
"options": {
|
||||||
"openclaw": {
|
"openclaw": {
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"gemini": {
|
"gemini": {
|
||||||
"api_key_env": "GEMINI_API_KEY",
|
"api_key_env": "GEMINI_API_KEY",
|
||||||
"model": "gemini-2.5-pro",
|
"model": "gemini-2.5-flash",
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7
|
"temperature": 0.7
|
||||||
}
|
}
|
||||||
@@ -78,8 +78,13 @@
|
|||||||
"provider": "smart_router",
|
"provider": "smart_router",
|
||||||
"options": {
|
"options": {
|
||||||
"smart_router": {
|
"smart_router": {
|
||||||
"priority": ["kling_free", "veo3", "seedance2", "ffmpeg_slides"],
|
"priority": [
|
||||||
"daily_cost_limit_usd": 0.50,
|
"kling_free",
|
||||||
|
"veo3",
|
||||||
|
"seedance2",
|
||||||
|
"ffmpeg_slides"
|
||||||
|
],
|
||||||
|
"daily_cost_limit_usd": 0.5,
|
||||||
"prefer_free_first": true,
|
"prefer_free_first": true,
|
||||||
"fallback": "ffmpeg_slides"
|
"fallback": "ffmpeg_slides"
|
||||||
},
|
},
|
||||||
@@ -191,11 +196,11 @@
|
|||||||
"analytics": "22:00"
|
"analytics": "22:00"
|
||||||
},
|
},
|
||||||
"brand": {
|
"brand": {
|
||||||
"name": "The 4th Path",
|
"name": "AI? 그게 뭔데?",
|
||||||
"sub": "Independent Tech Media",
|
"sub": "eli의 쉽고 재미있는 이야기",
|
||||||
"by": "by 22B Labs",
|
"by": "by eli",
|
||||||
"url": "the4thpath.com",
|
"url": "eli-ai.blogspot.com",
|
||||||
"cta": "팔로우하면 매일 이런 정보를 받습니다"
|
"cta": "구독하면 매일 쉽고 재미있는 정보를 받습니다"
|
||||||
},
|
},
|
||||||
"optional_keys": {
|
"optional_keys": {
|
||||||
"KLING_API_KEY": "Kling 3.0 AI 영상 생성 (66 무료 크레딧/일)",
|
"KLING_API_KEY": "Kling 3.0 AI 영상 생성 (66 무료 크레딧/일)",
|
||||||
@@ -204,4 +209,4 @@
|
|||||||
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
||||||
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"legal_keywords": [
|
"legal_keywords": [
|
||||||
"불법", "위법", "처벌", "벌금", "징역", "기소"
|
"불법", "위법", "처벌", "벌금", "징역", "기소"
|
||||||
],
|
],
|
||||||
"always_manual_review": ["팩트체크"],
|
"always_manual_review": ["팩트체크", "재테크절약"],
|
||||||
"min_sources_required": 2,
|
"min_sources_required": 2,
|
||||||
"min_quality_score_for_auto": 75
|
"min_quality_score_for_auto": 101
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,62 @@
|
|||||||
{
|
{
|
||||||
"rss_feeds": [
|
"rss_feeds": [
|
||||||
{
|
{ "name": "GeekNews", "url": "https://feeds.feedburner.com/geeknews-feed", "category": "AI인사이트", "trust_level": "high" },
|
||||||
"name": "GeekNews",
|
{ "name": "ZDNet Korea", "url": "https://www.zdnet.co.kr/rss/rss.php", "category": "AI인사이트", "trust_level": "high" },
|
||||||
"url": "https://feeds.feedburner.com/geeknews-feed",
|
{ "name": "연합뉴스 IT", "url": "https://www.yna.co.kr/rss/it.xml", "category": "AI인사이트", "trust_level": "high" },
|
||||||
"category": "tech",
|
{ "name": "AI타임스", "url": "https://www.aitimes.com/rss/allArticle.xml", "category": "AI인사이트", "trust_level": "medium" },
|
||||||
"trust_level": "high"
|
{ "name": "테크크런치 AI", "url": "https://techcrunch.com/category/artificial-intelligence/feed/", "category": "AI인사이트", "trust_level": "high" },
|
||||||
},
|
{ "name": "MIT 테크리뷰", "url": "https://www.technologyreview.com/feed/", "category": "AI인사이트", "trust_level": "high" },
|
||||||
{
|
{ "name": "전자신문", "url": "https://www.etnews.com/rss/rss.xml", "category": "AI인사이트", "trust_level": "high" },
|
||||||
"name": "ZDNet Korea",
|
|
||||||
"url": "https://www.zdnet.co.kr/rss/rss.php",
|
{ "name": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
|
||||||
"category": "tech",
|
{ "name": "플래텀", "url": "https://platum.kr/feed", "category": "스타트업", "trust_level": "high" },
|
||||||
"trust_level": "high"
|
{ "name": "벤처스퀘어", "url": "https://www.venturesquare.net/feed", "category": "스타트업", "trust_level": "medium" },
|
||||||
},
|
{ "name": "한국경제 IT", "url": "https://www.hankyung.com/feed/it", "category": "스타트업", "trust_level": "high" },
|
||||||
{
|
{ "name": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
|
||||||
"name": "Yonhap IT",
|
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
|
||||||
"url": "https://www.yna.co.kr/rss/it.xml",
|
|
||||||
"category": "tech",
|
{ "name": "연합뉴스 여행", "url": "https://www.yna.co.kr/rss/travel.xml", "category": "여행맛집", "trust_level": "high" },
|
||||||
"trust_level": "high"
|
{ "name": "경향신문 여행", "url": "https://www.khan.co.kr/rss/rssdata/kh_travel.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||||
},
|
{ "name": "한국관광공사", "url": "https://kto.visitkorea.or.kr/rss/rss.kto", "category": "여행맛집", "trust_level": "medium" },
|
||||||
{
|
{ "name": "대한항공 뉴스", "url": "https://www.koreanair.com/content/koreanair/global/en/footer/about-korean-air/news-and-pr/press-releases.rss.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||||
"name": "Bloter",
|
{ "name": "론리플래닛", "url": "https://www.lonelyplanet.com/news/feed", "category": "여행맛집", "trust_level": "medium" },
|
||||||
"url": "https://www.bloter.net/feed",
|
|
||||||
"category": "tech",
|
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
|
||||||
"trust_level": "high"
|
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
|
||||||
}
|
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
|
||||||
|
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
|
||||||
|
|
||||||
|
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
|
||||||
|
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
|
||||||
|
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "생활꿀팁", "trust_level": "medium" },
|
||||||
|
{ "name": "조선일보 라이프", "url": "https://www.chosun.com/arc/outboundfeeds/rss/category/life/", "category": "생활꿀팁", "trust_level": "high" },
|
||||||
|
|
||||||
|
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "앱추천", "trust_level": "medium" },
|
||||||
|
{ "name": "테크크런치 앱", "url": "https://techcrunch.com/category/apps/feed/", "category": "앱추천", "trust_level": "high" },
|
||||||
|
{ "name": "앱스토리", "url": "https://www.appstory.co.kr/rss/rss.xml", "category": "앱추천", "trust_level": "medium" },
|
||||||
|
|
||||||
|
{ "name": "매일경제 IT", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크절약", "trust_level": "high" },
|
||||||
|
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크절약", "trust_level": "high" },
|
||||||
|
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크절약", "trust_level": "high" },
|
||||||
|
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크절약", "trust_level": "medium" },
|
||||||
|
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크절약", "trust_level": "high" },
|
||||||
|
|
||||||
|
{ "name": "연합뉴스 팩트체크", "url": "https://www.yna.co.kr/rss/factcheck.xml", "category": "팩트체크", "trust_level": "high" },
|
||||||
|
{ "name": "SBS 뉴스", "url": "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=01&plink=RSSREADER", "category": "팩트체크", "trust_level": "high" },
|
||||||
|
{ "name": "KBS 뉴스", "url": "https://news.kbs.co.kr/rss/rss.xml", "category": "팩트체크", "trust_level": "high" },
|
||||||
|
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" }
|
||||||
],
|
],
|
||||||
"x_keywords": [
|
"x_keywords": [
|
||||||
"바이브코딩",
|
"AI 사용법",
|
||||||
"vibe coding",
|
|
||||||
"AI 자동화",
|
|
||||||
"Claude 사용",
|
|
||||||
"ChatGPT 활용",
|
"ChatGPT 활용",
|
||||||
"비개발자 앱",
|
"Claude 사용",
|
||||||
"노코드 AI"
|
"인공지능 추천",
|
||||||
|
"앱 추천",
|
||||||
|
"생활꿀팁",
|
||||||
|
"맛집 추천",
|
||||||
|
"스타트업 소식",
|
||||||
|
"재테크 방법",
|
||||||
|
"쇼핑 추천"
|
||||||
],
|
],
|
||||||
"github_trending": {
|
"github_trending": {
|
||||||
"url": "https://github.com/trending",
|
"url": "https://github.com/trending",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent.parent
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def get_subscriptions():
|
|||||||
"""구독 정보 + 만료일 계산"""
|
"""구독 정보 + 만료일 계산"""
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
load_dotenv()
|
||||||
|
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
for plan in SUBSCRIPTION_PLANS:
|
for plan in SUBSCRIPTION_PLANS:
|
||||||
|
|||||||
2992
dashboard/frontend/package-lock.json
generated
Normal file
2992
dashboard/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
x-common: &common
|
||||||
|
image: blog-writer:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
volumes:
|
||||||
|
- ./bots:/app/bots
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./templates:/app/templates
|
||||||
|
- ./dashboard/backend:/app/dashboard/backend
|
||||||
|
- ./dashboard/__init__.py:/app/dashboard/__init__.py
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./assets:/app/assets
|
||||||
|
- ./runtime_guard.py:/app/runtime_guard.py
|
||||||
|
- ./blog_engine_cli.py:/app/blog_engine_cli.py
|
||||||
|
- ./blog_runtime.py:/app/blog_runtime.py
|
||||||
|
- ./credentials.json:/app/credentials.json:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
services:
|
||||||
|
scheduler:
|
||||||
|
<<: *common
|
||||||
|
container_name: blog-scheduler
|
||||||
|
command: ["python3", "bots/scheduler.py"]
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
<<: *common
|
||||||
|
container_name: blog-dashboard
|
||||||
|
command: ["python3", "-m", "uvicorn", "dashboard.backend.server:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
@@ -23,8 +23,9 @@ openai
|
|||||||
pydub
|
pydub
|
||||||
# Phase 2 (YouTube 업로드 진행 표시)
|
# Phase 2 (YouTube 업로드 진행 표시)
|
||||||
google-resumable-media
|
google-resumable-media
|
||||||
# Phase 3 (엔진 추상화 — 선택적 의존성)
|
# Phase 3 (엔진 추상화)
|
||||||
# google-generativeai # Gemini Writer / Veo 사용 시 pip install google-generativeai
|
google-generativeai
|
||||||
|
groq
|
||||||
# Shorts Bot (Phase A)
|
# Shorts Bot (Phase A)
|
||||||
edge-tts>=6.1.0
|
edge-tts>=6.1.0
|
||||||
# openai-whisper # Edge TTS 단어별 타임스탬프용 (선택, pip install openai-whisper)
|
# openai-whisper # Edge TTS 단어별 타임스탬프용 (선택, pip install openai-whisper)
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ def ensure_project_runtime(
|
|||||||
entrypoint: str,
|
entrypoint: str,
|
||||||
required_distributions: list[str] | None = None,
|
required_distributions: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Docker 환경에서는 venv 체크를 건너뜀
|
||||||
|
if os.getenv('PYTHONPATH') == '/app' or not VENV_DIR.exists():
|
||||||
|
return
|
||||||
|
|
||||||
expected_python = project_python_path()
|
expected_python = project_python_path()
|
||||||
current_python = Path(sys.executable)
|
current_python = Path(sys.executable)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user