1 Commits

Author SHA1 Message Date
JOUNGWOOK KWON 3e2405dff9 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>
2026-03-30 09:21:14 +09:00
36 changed files with 3380 additions and 84 deletions
+7
View File
@@ -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
View 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
View 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
View 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; }
}
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'))
+1 -1
View File
@@ -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'))
+1 -1
View File
@@ -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'))
+1 -1
View File
@@ -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
+20 -1
View File
@@ -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
+1 -1
View File
@@ -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', '')
+11 -13
View File
@@ -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()
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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))
+5 -4
View File
@@ -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인사이트", "여행맛집", "스타트업", "제품리뷰", "생활꿀팁", "앱추천", "재테크절약", "팩트체크"]
} }
] ]
} }
+15 -10
View File
@@ -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 영상 생성"
} }
} }
+2 -2
View File
@@ -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
} }
+54 -30
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+35
View 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"
+3 -2
View File
@@ -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)
+4
View File
@@ -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)