brunch-diary / app.py
openfree's picture
add /api/threads_search (keyword_search proxy, key-guarded)
f9b2cfb verified
Raw
History Blame Contribute Delete
125 kB
"""
PRODUCT_BrunchDiary-HFSpace
Author: VIDRAFT
Created: 2026-05-30
Purpose: 4시간마다 GitHub 장기기억 + HF 뉴스 기반 브런치 에세이 + fal.ai 이미지 자동생성
Inputs: ANTHROPIC_API_KEY, GH_MEMORY_TOKEN, FAL_KEY, HF_TOKEN (HF Secrets)
Outputs: VIDraft/brunch-diary-archive (private dataset), HF Space UI
Dependencies: fastapi, uvicorn, requests, huggingface_hub, anthropic
"""
import os
import json
import time
import base64
import re
import io
import threading
import datetime
import requests
import anthropic
from fastapi import FastAPI, Request
from fastapi.responses import Response, HTMLResponse, JSONResponse, FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from huggingface_hub import HfApi, hf_hub_download
# ── 환경변수 (HF Secrets) ─────────────────────────────────────────
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
GH_MEMORY_TOKEN = os.environ.get("GH_MEMORY_TOKEN", "")
FAL_KEY = os.environ.get("FAL_KEY", "")
HF_TOKEN = os.environ.get("HF_TOKEN", "")
DATASET_REPO = "VIDraft/brunch-diary-archive"
# ── 멀티플랫폼 배포 토큰 (HF Secrets) ────────────────────────────
MEDIUM_TOKEN = os.environ.get("MEDIUM_TOKEN", "")
DEVTO_API_KEY = os.environ.get("DEVTO_API_KEY", "")
# 수동 발행 전용 인증키 — DEVTO_API_KEY와 분리해, 운영자(로컬)가 아는 값으로 관리 엔드포인트를 호출.
# DEVTO_API_KEY는 dev.to 발행 자격증명이라 로컬에 두면 안 되므로, 호출 인증은 이 별도 키로 뚫는다.
MANUAL_PUBLISH_KEY = os.environ.get("MANUAL_PUBLISH_KEY", "")
def _manual_auth_ok(body: dict) -> bool:
"""관리 엔드포인트 인증: DEVTO_API_KEY 또는 MANUAL_PUBLISH_KEY 중 하나와 일치하면 통과."""
k = str((body or {}).get("key", ""))
allowed = {x for x in (DEVTO_API_KEY, MANUAL_PUBLISH_KEY) if x}
return bool(allowed) and k in allowed
THREADS_USER_ID = os.environ.get("THREADS_USER_ID", "")
THREADS_ACCESS_TOKEN = os.environ.get("THREADS_ACCESS_TOKEN", "")
HASHNODE_API_KEY = os.environ.get("HASHNODE_API_KEY", "")
HASHNODE_PUBLICATION_ID = os.environ.get("HASHNODE_PUBLICATION_ID", "")
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHANNEL_ID = os.environ.get("TELEGRAM_CHANNEL_ID", "")
BLOGGER_BLOG_ID = os.environ.get("BLOGGER_BLOG_ID", "")
BLOGGER_ACCESS_TOKEN = os.environ.get("BLOGGER_ACCESS_TOKEN", "")
app = FastAPI()
# ── CORS: 모든 Origin 허용 (VIDraft/AI Space 등 *.hf.space 포함) ──
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 모든 도메인 허용 (public API)
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# ── 인메모리 상태 ─────────────────────────────────────────────────
articles_cache: list = []
is_generating: bool = False
last_generated: str = ""
next_gen_time: str = ""
last_devto_result: str = "" # 마지막 Dev.to 발행 결과(진단용)
# ── Medium publishing rate-limit 트래커 ───────────────────────────
# 429 수신 시 23시간 스킵 (Medium 일별 한도 초과 → 재시도 무의미)
_medium_rate_limit_until: float = 0.0 # unix timestamp
# ═══════════════════════════════════════════════════════════════════
# 1. 데이터 수집
# ═══════════════════════════════════════════════════════════════════
def fetch_github_memory() -> str:
"""GitHub 장기기억 CHANGELOG → 공개 가능 인사이트 추출"""
if not GH_MEMORY_TOKEN:
return ""
headers = {
"Authorization": f"Bearer {GH_MEMORY_TOKEN}",
"Accept": "application/vnd.github+json"
}
base = "https://api.github.com/repos/seawolf2357/memory/contents"
result = []
for path in ["shared/CHANGELOG.md"]:
try:
r = requests.get(f"{base}/{path}", headers=headers, timeout=10)
if r.status_code == 200:
raw = base64.b64decode(r.json()["content"].replace("\n", "")).decode("utf-8")
result.append("\n".join(raw.split("\n")[:60]))
except Exception as e:
print(f"GitHub fetch {path} failed: {e}")
return "\n\n".join(result)
def fetch_ginigen_today() -> str:
"""ginigen/Today Space API → 뉴스+모델+스페이스 종합 수집"""
try:
r = requests.get(
"https://ginigen-today.hf.space/api/data",
timeout=30
)
if r.status_code != 200:
print(f"ginigen/Today API: {r.status_code}")
return ""
payload = r.json()
if not payload.get("success"):
return ""
data = payload.get("data", {})
sections = []
# ① 뉴스 (AI Times + Hacker News)
news_items = data.get("analyzed_news", [])
if news_items:
lines = ["【오늘의 AI 뉴스 — AI Times · Hacker News】"]
for item in news_items[:8]:
title = item.get("title", "")
source = item.get("source", "")
analysis = item.get("analysis", {})
if isinstance(analysis, dict):
summary = analysis.get("summary", "")[:180]
impact = analysis.get("impact_level", "")
significance = analysis.get("significance", "")[:100]
else:
summary = str(analysis)[:180]
impact = ""
significance = ""
badge = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(impact, "")
lines.append(f"\n{badge} **{title}** ({source})")
if summary:
lines.append(f" → {summary}")
if significance:
lines.append(f" 💡 {significance}")
sections.append("\n".join(lines))
# ② HF 트렌딩 모델 Top 5
models = data.get("analyzed_models", [])
if models:
lines = ["【HuggingFace 트렌딩 모델 Top 5】"]
for m in models[:5]:
name = m.get("name", "")
analysis = m.get("analysis", "")[:120]
dl = m.get("downloads", 0)
lines.append(f"- **{name}** (↓{dl:,}) — {analysis}")
sections.append("\n".join(lines))
# ③ HF 인기 스페이스 Top 5
spaces = data.get("analyzed_spaces", [])
if spaces:
lines = ["【HuggingFace 인기 스페이스 Top 5】"]
for s in spaces[:5]:
name = s.get("name", "") or s.get("space_id", "")
explanation = s.get("simple_explanation", "")[:120]
lines.append(f"- **{name}** — {explanation}")
sections.append("\n".join(lines))
result = "\n\n".join(sections)
print(f" ginigen/Today: 뉴스 {len(news_items)}건, 모델 {len(models)}개, 스페이스 {len(spaces)}개")
return result
except Exception as e:
print(f"ginigen/Today fetch failed: {e}")
return ""
# ═══════════════════════════════════════════════════════════════════
# 2. 콘텐츠 생성
# ═══════════════════════════════════════════════════════════════════
STYLE_SYSTEM = """당신은 비드래프트(VIDraft) AI 연구소의 브런치 칼럼니스트입니다.
아래는 실제 브런치 베스트 에세이 "양자컴퓨터를 만들지 않고, 양자컴퓨터에 뛰어든 회사"를
분석해 추출한 문체 규칙입니다. 이 글이 당신의 교과서입니다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【이 스타일의 정체 — 가장 중요】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"냉철한 저널리스트가 쓴 내부자 에세이"
감성 일기가 아니다. 뜨거운 감동도 없다.
그런데 읽고 나면 무언가 남는다.
왜냐하면: 사실이 구체적이고, 실패를 숨기지 않고, 비유가 정확하고,
마무리가 감동을 강요하지 않기 때문이다.
임팩트의 원천은 화려한 수사가 아니라 → 절제된 정확함이다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【구조 — 볼드 소제목으로 나뉘는 저널리즘 에세이】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
① 제목: 역설 또는 충격 사실
실제 예: "양자컴퓨터를 만들지 않고, 양자컴퓨터에 뛰어든 회사"
형식: [행동 A를 하지 않고] + [행동 A의 영역에 뛰어든]
또는: [믿기 어려운 사실] + [그런데 그것이 사실이다]
② 오프닝: "잘 알려지지 않은 규칙이 있다"
첫 문장 = 독자를 업계 내부자로 대우하며 비밀을 공유하는 방식
실제 예: "요즘 인공지능 업계에는 잘 알려지지 않은 규칙이 하나 있다."
절대 금지: "오늘은 ~에 대해 이야기하겠습니다"
③ 3단 병렬 → 반전 착지 (리듬의 핵심)
실제 예: "오픈AI는 ~하지 않는다. 알리바바와 바이두는 ~문을 닫았다.
그런데 서울의 한 작은 스타트업이 조용히..."
공식: [거인1 + 상황] → [거인2 + 상황] → [그런데 우리는...]
④ 볼드 소제목 의무 사용 (5-8개)
실제 예: **먼저, 양자컴퓨터가 뭐길래**
**거인들의 게임, 그리고 작은 회사의 다른 선택**
**진짜 흥미로운 이야기는, 양자 이전에 있었다**
**그리고 한 번의 솔직한 실패**
소제목은 독자의 다음 궁금증을 대신 발화하는 형식으로
소제목마다 충분한 분량(400-600자)을 할당해 깊이 전개한다
⑤ 한 문장 비유 (추상→구체)
실제 예: "천재인데 지나치게 예민한 학생" (양자컴퓨터)
"0과 1을 또박또박 계산하는 모범생" (기존 컴퓨터)
규칙: 기술 개념 → 인간 캐릭터 or 일상 사물. 딱 한 문장. 더 이상 설명하지 않는다.
⑥ 구체적 숫자 연속 투하 (신뢰의 근거)
실제 예: "727개, 1000여 건, 290여 건, 62만 건, 98%, 47만 회, 290만 개"
규칙: 숫자를 빠르게 연속으로 제시 → 독자가 스스로 규모를 가늠하게 한다
절대 금지: 수치 없이 "엄청난", "놀라운", "대단한" 같은 형용사만 쓰기
※ 뉴스에 없는 수치는 절대 만들지 않는다
⑦ 실패를 한 문장으로 (가장 강력한 신뢰 구축)
실제 예: "그런데 이 방식은 실패했다."
규칙: 실패를 인정할 때 단 한 문장. 변명 없음. 설명 없음. 바로 다음 문장에서 전환.
효과: 독자가 "이 사람은 거짓말을 안 한다"고 느끼는 순간
⑧ 역할 전환 비유 (해결책 설명법)
실제 예: AI의 역할 = "경찰" → "조율사"로 전환
공식: [실패한 역할] → [새로운 역할]. 두 단어면 충분하다.
⑨ 자기 한계 명시 (오히려 신뢰를 높이는 절제)
실제 예: "이것은 특정 조건에 한정된 시뮬레이션 결과이지,
실제 양자 기계에서 완성했다는 뜻은 아니라는 것."
규칙: 성과 직후 반드시 "하지만 이것이 아직은..."을 붙인다
효과: "세계 최초"를 외치지 않는 절제가 오히려 설득력을 만든다
⑩ 수미상관 마무리 (제목 키워드로 돌아오기)
실제 예: 제목의 "씨앗 스무 개" → 마지막 줄 "씨앗 스무 개가 만든 풍경치고는, 꽤 흥미로운 이야기다."
규칙: 첫 문단 또는 제목의 핵심 단어·이미지를 마지막 줄에서 다시 꺼낸다
마무리 어조: "꽤 흥미로운", "아직 갈 길이 멀다" — 절제된 자신감. 감동 강요 없음.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【거인 vs 우리 — 감정적 엔진】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
이 스타일의 감정적 에너지는 "다윗과 골리앗" 대비에서 나온다.
"거인들이 수조 원짜리 기계를 짓는 동안, 서울의 한 작은 회사는..."
이 대비를 글 전체에서 최소 2-3회 활용한다.
거창하게 선언하지 않는다. 숫자와 사실로 보여주면 독자가 알아서 느낀다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【환각 방지 — 절대 규칙】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- 제공된 뉴스에 없는 수치·사건을 절대 생성하지 않는다
- 수치가 없으면 비유와 관찰로 대체한다 ("규모를 정확히 알 수 없지만, 분위기는...")
- 미공개 내부 정보(가중치·파라미터·계약·재무) 절대 금지
- 회사명·사람 이름은 뉴스에 나온 것만 사용
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【문단 리듬】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
짧은(1-2문장) ↔ 긴(4-6문장) 교차.
가장 중요한 문장 앞뒤는 반드시 한 문장 단락으로 고립시킨다.
분량: 6000-7000자. 이보다 짧으면 서사가 얕다. 절대 줄이지 말 것.
각 소제목 섹션은 최소 400자 이상 깊이 전개한다.
출력 형식:
# [역설형 또는 충격사실형 제목]
*[한 줄 부제 — 독자에게 "이게 왜 중요한가"를 알려주는 문장]*
> **TL;DR**: [이 글의 핵심을 2-3문장으로. 사실 기반, 키워드 포함. AI 엔진이 여기서 답변을 추출한다.]
[오프닝 문단 — "잘 알려지지 않은 규칙이 하나 있다" 형식]
**[첫 번째 소제목]**
[본문 400자 이상]
**[두 번째 소제목]**
[본문 400자 이상]
**[세 번째 소제목]**
[본문 400자 이상]
**[네 번째 소제목]**
[본문 400자 이상]
**[다섯 번째 소제목]**
[본문 400자 이상]
(필요시 6-8번째 소제목 추가)
[수미상관 마무리 — 제목 키워드 회귀 + 절제된 한 줄]
---
*더 많은 AI 인사이트는 [비드래프트](https://vidraft.net)에서 확인하세요.*
---
## 자주 묻는 질문
**Q. [독자가 가장 먼저 궁금해할 질문]?**
A. [명확하고 사실에 근거한 답변. 2-3문장.]
**Q. [두 번째 질문]?**
A. [답변.]
**Q. [세 번째 질문]?**
A. [답변.]
**Q. [네 번째 질문]?**
A. [답변.]
---"""
def generate_article(hf_news: str, used_topics: list = None) -> str:
"""Anthropic Claude API → 브런치 에세이 생성 (중복 주제 금지)
⚠️ 내부 CHANGELOG·연구 데이터는 절대 수신하지 않음 — 공개 안전 컨텍스트만 사용"""
if not ANTHROPIC_API_KEY:
print("ANTHROPIC_API_KEY 없음")
now = datetime.datetime.now().strftime("%Y년 %m월 %d일")
return f"# AI와 함께하는 {now}\n\n*(ANTHROPIC_API_KEY Secret을 설정해주세요)*"
try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
today = datetime.datetime.now().strftime("%Y년 %m월 %d일")
# 중복 주제 방지 섹션
used_topics_block = ""
if used_topics:
titles_list = "\n".join(f" - {t}" for t in used_topics[:30])
used_topics_block = f"""
━━━━━━━━━━━━━━━━━━
【⛔ 이미 쓴 주제 — 절대 중복 금지】
━━━━━━━━━━━━━━━━━━
아래 제목들과 동일하거나 유사한 주제·각도는 절대 선택하지 않는다.
뉴스가 같더라도 완전히 다른 각도, 다른 등장인물, 다른 핵심 질문을 찾아야 한다.
{titles_list}
→ 위 목록에 없는 새로운 뉴스 항목 OR 같은 뉴스라도 완전히 다른 시각을 선택할 것.
"""
# ── 안전한 공개 가능 회사 맥락 (CHANGELOG/내부 연구 데이터 절대 사용 금지) ──
SAFE_COMPANY_CONTEXT = """비드래프트(VIDRAFT)는 한국의 Pre-AGI 전문 AI 스타트업이다.
Darwin 모델 패밀리(GPQA Diamond 글로벌 3위), AETHER 하이브리드 어텐션 아키텍처,
PharmaOS 신약 발견 플랫폼, NationalOS 인공사회 시뮬레이션을 개발하고 있다.
HuggingFace 공인 협력사이며 K-AI 리더보드 상위권을 유지하고 있다."""
prompt = f"""오늘은 {today}입니다.
━━━━━━━━━━━━━━━━━━
【오늘의 AI 뉴스 — 이 사실만 근거로 쓸 것】
━━━━━━━━━━━━━━━━━━
{hf_news[:2800]}
━━━━━━━━━━━━━━━━━━
【필자 소개 (공개 정보만)】
━━━━━━━━━━━━━━━━━━
{SAFE_COMPANY_CONTEXT}
━━━━━━━━━━━━━━━━━━
【🚨 절대 금지 — 영업기밀 보호】
━━━━━━━━━━━━━━━━━━
다음 내용은 어떤 형태로도 글에 포함하면 안 된다. 위반 시 발행 자동 차단.
❌ 내부 실험 수치: p값, McNemar 검정 결과, 셀 수, job ID, 오류율 퍼센트
❌ 내부 연구 단계: Phase 번호(Phase 1, Phase 18 등), 실험 코드명
❌ 미공개 모델 세부 사항: threshold 값, learning rate, batch size, step 수, loss 수치
❌ 인프라 세부: 특정 GPU 서버 이름, 컨테이너 ID, 내부 API 엔드포인트
❌ QEC 내부 결과: IBM QPU 실험 데이터, surface code 수치, predecoder 설정
❌ 사업 내부 정보: 계약 금액, 미체결 투자, 파트너 협상 상태, 재무 수치
❌ 미공개 파트너십: 협약 전 파트너, PoC 진행 중 기업명
✅ 허용: 이미 공개된 벤치마크 순위, HuggingFace 공개 모델명, 회사 공식 소개
{used_topics_block}
━━━━━━━━━━━━━━━━━━
【작성 절차 — 순서대로 실행】
━━━━━━━━━━━━━━━━━━
[1단계] 뉴스에서 주제 하나 선택
- 가장 역설적이거나 의외인 것
- 거인(Big Tech) vs 작은 플레이어 대비가 뚜렷한 것
- 여러 뉴스 나열 금지 — 하나를 깊이 파고들 것
[2단계] 제목과 오프닝 설계
- 제목: 역설형 ("~를 하지 않고, ~에 뛰어든") 또는 충격사실형
- 오프닝 첫 문장: "~업계에는 잘 알려지지 않은 규칙이 하나 있다" 형식으로
- 3단 병렬 (거인1 → 거인2 → 그런데 우리는) 즉시 활용
[3단계] 볼드 소제목 5-8개로 구조화
- 각 소제목은 독자의 다음 궁금증을 대신 발화
- 소제목마다 최소 400자씩 깊이 전개한다
- 각 섹션 안에서: 한 문장 비유 → 구체적 사례 → 관찰과 해석 → 감정적 맥락
- 배경 설명, 업계 맥락, 역사적 비교를 충분히 활용해 두께를 만든다
[4단계] 수미상관 마무리
- 제목이나 오프닝의 핵심 단어를 마지막 문장에서 다시 꺼낸다
- "꽤 흥미로운 이야기다" 식의 절제된 한 줄로 닫는다
[5단계] 분량 확인 — 가장 중요
- 6000-7000자를 반드시 채운다
- 부족하면: 각 소제목 섹션으로 돌아가 배경·비유·사례를 더 추가한다
- 글이 중간에 끊기는 느낌 없이 자연스럽게 흘러야 한다
[6단계] 환각 체크
- 뉴스에 없는 수치는 삭제하고 비유로 대체
- 미공개 내부 정보 없는지 확인
지금 바로 제목부터 시작하세요. 서론 설명 없이."""
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=8000,
system=STYLE_SYSTEM,
messages=[{"role": "user", "content": prompt}]
)
article = message.content[0].text.strip()
# ── 영업기밀 키워드 사후 검사 ─────────────────────────────────
SECRET_KEYWORDS = [
# QEC / 양자 내부 수치
"McNemar", "SIG_BETTER", "SIG_WORSE", "WEAK_BETTER",
"IBM QPU", "Heron r", "kingston", "marrakesh", "fez",
"surface code 수치", "predecoder",
# 내부 실험 Phase 번호
"Phase 15", "Phase 16", "Phase 17", "Phase 18",
"Phase 19", "Phase 20", "Phase 20-A", "Phase 20-B",
# 내부 수치 패턴
"job ID", "컨테이너 id", "container id",
"p=4.20", "p=9.79", "p=1.93",
# 내부 포트 / 엔드포인트
"7860", "7861", ":7878", ":7901", ":7902",
# 학습 내부 수치 (영문 단독 사용 시만 — 맥락 의존)
"loss 수치", "step 수",
]
found = [kw for kw in SECRET_KEYWORDS if kw.lower() in article.lower()]
if found:
print(f" ⛔ 영업기밀 감지 → 발행 차단: {found}")
return None # None 반환 → run_generation에서 발행 스킵
return article
except Exception as e:
print(f"Anthropic API failed: {e}")
now = datetime.datetime.now().strftime("%Y년 %m월 %d일")
return f"# AI와 함께하는 {now}\n\n글 생성 오류: {e}"
def generate_image_prompts(article_text: str) -> list:
"""Claude → 기사 분위기 기반 이미지 프롬프트 4개"""
defaults = [
"Korean morning coffee ritual, soft warm light, minimalist table setup, contemplative mood, cinematic",
"Late night AI researcher, city lights through window, peaceful solitude, blue hour, artistic",
"Abstract data flows becoming nature, cherry blossoms and circuits, watercolor digital fusion",
"Small team celebrating breakthrough, warm studio light, collaborative spirit, hopeful dawn"
]
if not ANTHROPIC_API_KEY:
return defaults
try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
msg = client.messages.create(
model="claude-haiku-4-5",
max_tokens=400,
messages=[{"role": "user", "content":
f"""다음 글의 분위기에 어울리는 영어 이미지 프롬프트 4개를 작성해주세요.
스타일: minimalist, Korean aesthetic, soft lighting, emotional, cinematic photography, no text in image, no faces
각 프롬프트를 한 줄씩, 번호 없이, 줄바꿈으로만 구분해서 출력하세요.
글:
{article_text[:600]}"""}]
)
lines = [l.strip() for l in msg.content[0].text.strip().split("\n") if l.strip()]
prompts = [l for l in lines if len(l) > 10][:4]
while len(prompts) < 4:
prompts.append(defaults[len(prompts)])
return prompts[:4]
except Exception as e:
print(f"Prompt gen failed: {e}")
return defaults
def generate_images_fal(prompts: list) -> list:
"""fal.ai nano-banana-2 → 이미지 생성"""
if not FAL_KEY:
print("FAL_KEY 없음 — 이미지 생성 건너뜀")
return []
images = []
for i, prompt in enumerate(prompts):
try:
# ① 요청 제출
sub = requests.post(
"https://queue.fal.run/fal-ai/nano-banana-2",
headers={"Authorization": f"Key {FAL_KEY}", "Content-Type": "application/json"},
json={"prompt": prompt, "aspect_ratio": "1:1", "resolution": "1K", "output_format": "png"},
timeout=30
)
if sub.status_code not in (200, 201):
print(f"Image {i} submit failed: {sub.status_code} {sub.text[:100]}")
continue
data = sub.json()
request_id = data.get("request_id", "")
status_url = data.get("status_url") or f"https://queue.fal.run/fal-ai/nano-banana-2/requests/{request_id}/status"
response_url = data.get("response_url") or f"https://queue.fal.run/fal-ai/nano-banana-2/requests/{request_id}/response"
# ② 완료 대기 (최대 90초)
for _ in range(45):
time.sleep(2)
st = requests.get(status_url, headers={"Authorization": f"Key {FAL_KEY}"}, timeout=10)
if st.status_code == 200:
status = st.json().get("status", "")
if status == "COMPLETED":
break
if status in ("FAILED", "CANCELLED"):
print(f"Image {i} {status}")
break
# ③ 결과 수집
res = requests.get(response_url, headers={"Authorization": f"Key {FAL_KEY}"}, timeout=30)
if res.status_code == 200:
imgs = res.json().get("images", [])
if imgs:
url = imgs[0].get("url", "")
if url:
images.append({"url": url, "prompt": prompt})
print(f" ✓ 이미지 {i}: {url[:60]}…")
except Exception as e:
print(f"Image {i} error: {e}")
return images
SPACE_BASE_URL = "https://vidraft-brunch-diary.hf.space"
BLOG_BASE_URL = os.environ.get("BLOG_BASE_URL", SPACE_BASE_URL) # 커스텀 도메인(blog.vidraft.net) 연결 시 BLOG_BASE_URL 변수로 교체
def embed_images_in_article(article_text: str, images: list, article_id: str) -> str:
"""이미지를 단락 경계(\n\n)에만 배치 — 문장 중간 삽입 절대 금지
배치 전략:
- 본문을 \n\n 으로 블록 분리 → 블록 사이(= 단락 끝)에만 삽입
- FAQ / 수평선(---) 섹션 이전까지만 배치
- n_images 개를 본문 블록 구간에 균등 분배
"""
if not images or not article_id:
return article_text
import re
n_images = min(len(images), 4)
def img_md(idx: int) -> str:
prompt = images[idx].get("prompt", f"이미지 {idx + 1}")[:60]
url = f"{SPACE_BASE_URL}/proxy/image/{article_id}/{idx}"
return f"![{prompt}]({url})"
# ── 1. 연속 빈 줄 정규화 후 블록 분리 ──────────────────────────
normalized = re.sub(r'\n{3,}', '\n\n', article_text.strip())
blocks = normalized.split('\n\n')
if len(blocks) <= 1:
return article_text
# ── 2. FAQ / 구분선 시작 블록 탐지 → 그 앞까지만 배치 허용 ─────
faq_start = len(blocks)
for i, blk in enumerate(blocks):
stripped = blk.strip()
if (stripped.startswith('## 자주') or
stripped.startswith('**Q.') or
re.match(r'^-{3,}$', stripped)):
faq_start = i
break
# 삽입 가능한 슬롯: 블록 인덱스 1 ~ faq_start-1 사이 (블록 앞에 이미지를 놓음)
slots = list(range(2, faq_start)) # 첫 번째 블록(제목) 직후는 건너뜀
if not slots:
# 슬롯 부족 시 faq_start 직전에 모두 모음
slots = [max(1, faq_start - 1)] * n_images
# ── 3. 균등 선택 ─────────────────────────────────────────────────
if len(slots) <= n_images:
chosen = slots[:n_images]
else:
step = len(slots) / n_images
chosen = [slots[int(step * k + step / 2)] for k in range(n_images)]
chosen = sorted(set(chosen))
# ── 4. 뒤에서부터 삽입 (인덱스 밀림 방지) ───────────────────────
for img_idx, blk_idx in reversed(list(enumerate(chosen[:n_images]))):
blocks.insert(blk_idx, img_md(img_idx))
return '\n\n'.join(blocks)
# ═══════════════════════════════════════════════════════════════════
# 3. SEO/AEO 메타데이터 생성
# ═══════════════════════════════════════════════════════════════════
def generate_seo_metadata(article_text: str) -> dict:
"""Claude → SEO/AEO 메타데이터 자동 생성"""
lines = article_text.strip().split("\n")
title = lines[0].lstrip("# ").strip()
defaults = {
"seo_title": title[:60],
"meta_description": " ".join(article_text.split())[:155],
"tags_ko": ["AI", "인공지능", "머신러닝", "기술", "스타트업"],
"tags_en": ["ai", "machinelearning", "technology", "startup", "korean"],
"primary_keyword": "AI"
}
if not ANTHROPIC_API_KEY:
return defaults
try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
msg = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
messages=[{"role": "user", "content": f"""다음 글의 SEO/AEO 메타데이터를 JSON으로 생성하세요.
글:
{article_text[:1500]}
아래 JSON 형식으로만 응답하세요 (설명·마크다운 없이 순수 JSON):
{{
"seo_title": "검색 최적화 제목 (60자 이내, 핵심 키워드 포함, 클릭을 유도)",
"meta_description": "메타 디스크립션 (155자 이내, 핵심 내용 + 클릭 유도)",
"tags_ko": ["한글태그1", "한글태그2", "한글태그3", "한글태그4", "한글태그5"],
"tags_en": ["english1", "english2", "english3", "english4"],
"primary_keyword": "가장 중요한 검색 키워드 하나"
}}"""}]
)
import re
raw = msg.content[0].text.strip()
json_match = re.search(r'\{[\s\S]*\}', raw)
if json_match:
return json.loads(json_match.group())
return defaults
except Exception as e:
print(f"SEO metadata gen failed: {e}")
return defaults
# ═══════════════════════════════════════════════════════════════════
# 4. 멀티플랫폼 배포
# ═══════════════════════════════════════════════════════════════════
def publish_devto(article_text: str, seo: dict, cover_image_url: str = "") -> dict:
"""Dev.to API → 글 게시"""
if not DEVTO_API_KEY:
return {"platform": "devto", "status": "skipped"}
lines = article_text.strip().split("\n")
title = lines[0].lstrip("# ").strip()
# Dev.to: 태그 최대 4개, 소문자 영숫자만 — 하이픈·점·특수문자 있으면 422 → 완전 제거 필수
import re as _re
tags = [_re.sub(r'[^a-z0-9]', '', str(t).lower())[:20] for t in seo.get("tags_en", ["ai", "technology"])[:4]]
tags = [t for t in tags if t][:4] or ["ai"]
try:
r = requests.post(
"https://dev.to/api/articles",
headers={"api-key": DEVTO_API_KEY, "Content-Type": "application/json"},
json={"article": {
"title": seo.get("seo_title", title)[:250],
"body_markdown": article_text,
"published": True,
"tags": tags,
"description": seo.get("meta_description", "")[:500],
"main_image": cover_image_url or "",
}},
timeout=30
)
if r.status_code in (200, 201):
data = r.json()
url = data.get("url", "")
post_id = data.get("id") # 통계 수집에 필요
print(f" ✓ Dev.to: {url} (id={post_id})")
return {"platform": "devto", "status": "ok", "url": url, "post_id": post_id}
print(f" ✗ Dev.to: {r.status_code} {r.text[:150]}")
return {"platform": "devto", "status": "error", "code": r.status_code}
except Exception as e:
print(f" ✗ Dev.to: {e}")
return {"platform": "devto", "status": "error", "reason": str(e)}
def publish_threads(article_text: str, seo: dict) -> dict:
if not THREADS_ENABLED:
return {"platform": "threads", "status": "disabled", "reason": "THREADS_ENABLED=0 (account safety hold)"}
"""Threads API → 텍스트 게시 (2단계: 컨테이너 생성 → 발행). 500자 제한."""
if not THREADS_USER_ID or not THREADS_ACCESS_TOKEN:
return {"platform": "threads", "status": "skipped"}
text = (seo.get("threads_text") or article_text or "").strip()
if len(text) > 495:
text = text[:492].rstrip() + "…"
try:
base = "https://graph.threads.net/v1.0/%s" % THREADS_USER_ID
c = requests.post(base + "/threads", data={
"media_type": "TEXT", "text": text, "access_token": THREADS_ACCESS_TOKEN,
}, timeout=30)
if c.status_code not in (200, 201):
return {"platform": "threads", "status": "error", "step": "container",
"code": c.status_code, "reason": c.text[:200]}
cid = c.json().get("id")
if not cid:
return {"platform": "threads", "status": "error", "reason": "no container id"}
time.sleep(3) # Threads 권장: 컨테이너 처리 대기
pub = requests.post(base + "/threads_publish", data={
"creation_id": cid, "access_token": THREADS_ACCESS_TOKEN,
}, timeout=30)
if pub.status_code in (200, 201):
pid = pub.json().get("id")
perma = ""
try:
pr = requests.get("https://graph.threads.net/v1.0/%s" % pid,
params={"fields": "permalink", "access_token": THREADS_ACCESS_TOKEN}, timeout=15)
if pr.status_code == 200:
perma = pr.json().get("permalink", "")
except Exception:
pass
print(" ✓ Threads: %s (%s)" % (pid, perma))
return {"platform": "threads", "status": "ok", "post_id": pid, "url": perma}
return {"platform": "threads", "status": "error", "step": "publish",
"code": pub.status_code, "reason": pub.text[:200]}
except Exception as e:
print(" ✗ Threads: %s" % e)
return {"platform": "threads", "status": "error", "reason": str(e)}
def publish_medium(article_text: str, seo: dict) -> dict:
"""Medium API → 글 게시 (스마트 스킵: 429 수신 후 23시간 재시도 금지)"""
global _medium_rate_limit_until
if not MEDIUM_TOKEN:
return {"platform": "medium", "status": "skipped"}
# ── 스마트 스킵: 아직 rate-limit 윈도우 안이면 즉시 반환 ──────
now = time.time()
if now < _medium_rate_limit_until:
remaining_h = (_medium_rate_limit_until - now) / 3600
reset_kst = datetime.datetime.fromtimestamp(
_medium_rate_limit_until,
tz=datetime.timezone(datetime.timedelta(hours=9))
).strftime("%H:%M KST")
print(f" ⏭ Medium: rate-limit 스킵 (리셋까지 {remaining_h:.1f}h, {reset_kst})")
return {
"platform": "medium",
"status": "rate_limited",
"reset_at": int(_medium_rate_limit_until),
"reason": f"daily rate-limit (resets ~{reset_kst})"
}
try:
# 사용자 ID 조회
me = requests.get(
"https://api.medium.com/v1/me",
headers={"Authorization": f"Bearer {MEDIUM_TOKEN}"},
timeout=10
)
if me.status_code == 429:
_medium_rate_limit_until = time.time() + 23 * 3600
reset_kst = datetime.datetime.fromtimestamp(
_medium_rate_limit_until,
tz=datetime.timezone(datetime.timedelta(hours=9))
).strftime("%m-%d %H:%M KST")
print(f" ✗ Medium 429 (/me) — 23h 스킵 설정 (리셋: {reset_kst})")
return {"platform": "medium", "status": "rate_limited",
"reset_at": int(_medium_rate_limit_until),
"reason": "publishing rate-limit (daily)"}
if me.status_code != 200:
return {"platform": "medium", "status": "error",
"reason": f"auth failed HTTP{me.status_code}", "code": me.status_code}
author_id = me.json().get("data", {}).get("id", "")
if not author_id:
return {"platform": "medium", "status": "error", "reason": "no author id"}
lines = article_text.strip().split("\n")
title = lines[0].lstrip("# ").strip()
tags = seo.get("tags_en", ["AI", "Technology", "MachineLearning"])[:5]
payload = {
"title": seo.get("seo_title", title)[:250],
"contentFormat": "markdown",
"content": article_text,
"tags": tags,
"publishStatus": "public"
}
r = requests.post(
f"https://api.medium.com/v1/users/{author_id}/posts",
headers={"Authorization": f"Bearer {MEDIUM_TOKEN}", "Content-Type": "application/json"},
json=payload,
timeout=30
)
if r.status_code in (200, 201):
url = r.json().get("data", {}).get("url", "")
print(f" ✓ Medium: {url}")
_medium_rate_limit_until = 0.0 # 성공 → 트래커 리셋
return {"platform": "medium", "status": "ok", "url": url}
if r.status_code == 429:
_medium_rate_limit_until = time.time() + 23 * 3600
reset_kst = datetime.datetime.fromtimestamp(
_medium_rate_limit_until,
tz=datetime.timezone(datetime.timedelta(hours=9))
).strftime("%m-%d %H:%M KST")
print(f" ✗ Medium 429 — 23h 스킵 설정 (리셋: {reset_kst})")
return {"platform": "medium", "status": "rate_limited",
"reset_at": int(_medium_rate_limit_until),
"reason": r.text[:200]}
print(f" ✗ Medium: HTTP{r.status_code} {r.text[:150]}")
return {"platform": "medium", "status": "error", "code": r.status_code,
"reason": r.text[:200]}
except Exception as e:
print(f" ✗ Medium: {e}")
return {"platform": "medium", "status": "error", "reason": str(e)}
def publish_blogger(article_text: str, seo: dict) -> dict:
"""Blogger API v3 → 글 게시"""
if not BLOGGER_ACCESS_TOKEN or not BLOGGER_BLOG_ID:
return {"platform": "blogger", "status": "skipped"}
try:
lines = article_text.strip().split("\n")
title = lines[0].lstrip("# ").strip()
# Markdown → HTML 간단 변환 (Blogger는 HTML)
import re
html = article_text
html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
html = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html)
html = re.sub(r'^> (.+)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
html = re.sub(r'\n\n', '</p><p>', html)
html = f"<p>{html}</p>"
labels = seo.get("tags_ko", [])[:5]
r = requests.post(
f"https://www.googleapis.com/blogger/v3/blogs/{BLOGGER_BLOG_ID}/posts/",
headers={"Authorization": f"Bearer {BLOGGER_ACCESS_TOKEN}", "Content-Type": "application/json"},
json={"kind": "blogger#post", "title": seo.get("seo_title", title), "content": html, "labels": labels},
timeout=30
)
if r.status_code in (200, 201):
url = r.json().get("url", "")
print(f" ✓ Blogger: {url}")
return {"platform": "blogger", "status": "ok", "url": url}
print(f" ✗ Blogger: {r.status_code} {r.text[:150]}")
return {"platform": "blogger", "status": "error", "code": r.status_code}
except Exception as e:
print(f" ✗ Blogger: {e}")
return {"platform": "blogger", "status": "error", "reason": str(e)}
def publish_telegram(article_text: str, seo: dict, article_id: str) -> dict:
"""Telegram Bot API → 채널 공유"""
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHANNEL_ID:
return {"platform": "telegram", "status": "skipped"}
try:
lines = article_text.strip().split("\n")
title = lines[0].lstrip("# ").strip()
# TL;DR 추출
tldr = seo.get("meta_description", "")
for i, line in enumerate(lines):
if "TL;DR" in line and i + 1 < len(lines):
tldr = lines[i + 1].strip().lstrip(">").strip()
break
tags_text = " ".join(f"#{t.replace(' ', '').replace('-', '')}"
for t in seo.get("tags_ko", ["AI"])[:5])
msg = f"✍️ *{title}*\n\n{tldr}\n\n{tags_text}\n\n📖 비드래프트 AI 다이어리"
r = requests.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
json={"chat_id": TELEGRAM_CHANNEL_ID, "text": msg, "parse_mode": "Markdown"},
timeout=15
)
if r.status_code == 200:
print(f" ✓ Telegram: 전송 완료")
return {"platform": "telegram", "status": "ok"}
print(f" ✗ Telegram: {r.status_code} {r.text[:100]}")
return {"platform": "telegram", "status": "error", "code": r.status_code}
except Exception as e:
print(f" ✗ Telegram: {e}")
return {"platform": "telegram", "status": "error", "reason": str(e)}
def publish_all_platforms(article_text: str, seo: dict, images: list, article_id: str) -> list:
"""활성 플랫폼에 순차 배포 (Medium 단독 — 회장님 지시 2026-07-08)"""
# ★ fal.ai 임시 URL(만료) 대신 영구 proxy URL 사용
cover_url = f"{SPACE_BASE_URL}/proxy/image/{article_id}/0" if images else ""
results = []
platforms = [
("Medium", lambda: publish_medium(article_text, seo)),
]
for name, fn in platforms:
try:
result = fn()
results.append(result)
if result.get("status") == "skipped":
print(f" ⏭ {name}: Secret 미설정")
except Exception as e:
print(f" ✗ {name}: {e}")
results.append({"platform": name.lower(), "status": "error", "reason": str(e)})
return results
# ═══════════════════════════════════════════════════════════════════
# 5-B. 노출·성과 측정 (Analytics)
# ═══════════════════════════════════════════════════════════════════
def collect_devto_stats(post_id: int) -> dict:
"""Dev.to 게시글 통계 수집"""
if not DEVTO_API_KEY or not post_id:
return {}
try:
r = requests.get(
f"https://dev.to/api/articles/{post_id}",
headers={"api-key": DEVTO_API_KEY},
timeout=10
)
if r.status_code == 200:
d = r.json()
return {
"views": d.get("page_views_count", 0),
"reactions": d.get("positive_reactions_count", 0),
"comments": d.get("comments_count", 0),
}
except Exception as e:
print(f"Dev.to stats error: {e}")
return {}
def collect_all_stats():
"""전체 글 통계 갱신 → HF dataset stats.json 업데이트"""
if not HF_TOKEN:
return
try:
index = load_index()
changed = False
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
for entry in index:
platforms = entry.get("platforms", {})
stats = entry.get("stats", {})
# Dev.to 통계 갱신
devto_id = platforms.get("devto", {}).get("post_id")
if devto_id:
devto_stats = collect_devto_stats(devto_id)
if devto_stats:
stats["devto"] = {**devto_stats, "updated_at": now_str}
changed = True
# 총합 계산
total_views = stats.get("devto", {}).get("views", 0)
stats["total_views"] = total_views
stats["updated_at"] = now_str
entry["stats"] = stats
if changed or True: # 항상 타임스탬프 갱신
api = HfApi(token=HF_TOKEN)
api.upload_file(
path_or_fileobj=json.dumps(index, ensure_ascii=False, indent=2).encode("utf-8"),
path_in_repo="index.json",
repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"📊 stats update: {now_str}"
)
# 캐시 갱신
global articles_cache
articles_cache = index
print(f"📊 통계 갱신 완료: {len(index)}개 글")
except Exception as e:
print(f"Stats collect error: {e}")
# ═══════════════════════════════════════════════════════════════════
# 5. 데이터셋 저장
# ═══════════════════════════════════════════════════════════════════
def load_index() -> list:
"""HF private 데이터셋 인덱스 로드"""
try:
path = hf_hub_download(DATASET_REPO, "index.json", repo_type="dataset", token=HF_TOKEN)
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def download_image(url: str) -> bytes:
try:
r = requests.get(url, timeout=30)
return r.content if r.status_code == 200 else b""
except Exception as e:
print(f"Image download failed: {e}")
return b""
def presave_images(article_id: str, images: list) -> list:
"""
★ 플랫폼 배포 전 이미지를 HF dataset에 먼저 저장.
이렇게 해야 Dev.to/Medium이 publish 시점에 proxy URL을 가져갈 수 있다.
반환값: saved_images list (repo_path 포함)
"""
if not HF_TOKEN or not images:
return images
api = HfApi(token=HF_TOKEN)
try:
api.create_repo(DATASET_REPO, repo_type="dataset", private=True, exist_ok=True)
except Exception:
pass
saved = []
for i, img in enumerate(images):
fal_url = img.get("url", "")
if not fal_url:
saved.append(img)
continue
img_bytes = download_image(fal_url)
if img_bytes:
repo_path = f"articles/{article_id}/image_{i}.png"
try:
api.upload_file(
path_or_fileobj=img_bytes,
path_in_repo=repo_path,
repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"🖼 presave {article_id} img{i}"
)
saved.append({"repo_path": repo_path, "prompt": img.get("prompt", "")})
print(f" ✓ 이미지 {i} HF 사전저장 완료 → /proxy/image/{article_id}/{i}")
except Exception as e:
print(f" ✗ 이미지 {i} HF 저장 실패: {e}")
saved.append({"fal_url": fal_url, "prompt": img.get("prompt", "")})
else:
print(f" ✗ 이미지 {i} fal.ai 다운로드 실패 (URL 만료 여부 확인)")
saved.append({"fal_url": fal_url, "prompt": img.get("prompt", "")})
# HF가 업로드를 처리할 시간을 약간 확보
if any("repo_path" in s for s in saved):
time.sleep(3)
return saved
def save_to_dataset(article_id: str, article_text: str, images: list,
seo: dict = None, pub_results: list = None) -> list:
"""article + images + SEO + 배포결과 → HF private dataset
images: presave_images()가 반환한 saved_images (이미 repo_path 포함)
"""
api = HfApi(token=HF_TOKEN)
try:
api.create_repo(DATASET_REPO, repo_type="dataset", private=True, exist_ok=True)
except Exception as e:
print(f"Dataset create_repo: {e}")
lines = article_text.strip().split("\n")
title = lines[0].lstrip("# ").strip() if lines else "제목 없음"
api.upload_file(
path_or_fileobj=article_text.encode("utf-8"),
path_in_repo=f"articles/{article_id}/article.md",
repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"✍ {article_id}: {title[:40]}"
)
# 이미지는 presave_images()에서 이미 업로드됨 → 재업로드 불필요
# fal_url만 있는 항목(presave 실패)만 재시도
saved_images = []
for i, img in enumerate(images):
if img.get("repo_path"):
# 이미 HF에 저장됨
saved_images.append(img)
else:
# presave 실패 → 재시도 (fal.ai URL이 살아있을 경우)
fal_url = img.get("fal_url", img.get("url", ""))
img_bytes = download_image(fal_url) if fal_url else b""
if img_bytes:
repo_path = f"articles/{article_id}/image_{i}.png"
try:
api.upload_file(
path_or_fileobj=img_bytes,
path_in_repo=repo_path,
repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"🖼 retry {article_id} img{i}"
)
saved_images.append({"repo_path": repo_path, "prompt": img.get("prompt", "")})
except Exception as e:
print(f"Image {i} retry save failed: {e}")
saved_images.append(img)
else:
saved_images.append(img)
# 플랫폼 배포 결과 → 딕셔너리 변환 (에러 이유 포함)
platforms_map = {}
for r in (pub_results or []):
p = r.get("platform", "unknown")
platforms_map[p] = {
"status": r.get("status"),
"url": r.get("url", ""),
"post_id": r.get("post_id"),
"code": r.get("code"), # HTTP 상태코드
"reason": r.get("reason", ""), # 에러 사유
}
index = load_index()
# created_at: Unix timestamp for sorting
try:
dt_obj = datetime.datetime.strptime(article_id, "%Y-%m-%d_%H%M")
except Exception:
dt_obj = datetime.datetime.now()
created_at_ts = int(dt_obj.timestamp())
entry = {
"id": article_id,
"title": title,
"ts": article_id,
"created_at": created_at_ts,
"preview": article_text[:350].replace("\n", " "),
"full": article_text,
"article_text": article_text, # blog card JS 호환 alias
"images": saved_images,
"seo": seo or {},
"platforms": platforms_map,
"stats": {"total_views": 0, "updated_at": ""}
}
index.insert(0, entry)
index = index[:60]
api.upload_file(
path_or_fileobj=json.dumps(index, ensure_ascii=False, indent=2).encode("utf-8"),
path_in_repo="index.json",
repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"📋 index update: {article_id}"
)
return index
# ═══════════════════════════════════════════════════════════════════
# 4. 스케줄러
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════
# Medium 뉴스 번역 발행 (회장님 지시 2026-07-08)
# 홈페이지 news.json 최신 뉴스 → 영어 브리프(출처명시·이미지 없음) → Medium
# 매일 08:00 KST 1회. 오늘 없으면 D-1 → D-2 순으로 미발행 1건.
# ═══════════════════════════════════════════════════════════════════
KST = datetime.timezone(datetime.timedelta(hours=9))
NEWS_JSON_URL = "https://vidraft.net/news.json"
POSTED_LOG_PATH = "posted_news.json"
POSTED_DEVTO_PATH = "posted_devto.json" # Dev.to 독립 게시로그
ENGLISH_SYSTEM = (
"You are the English editorial writer for VIDRAFT (비드래프트), a Korean Pre-AGI AI startup. "
"You turn Korean press coverage about VIDRAFT into SEO- and AEO-optimized English news articles for Medium. "
"SEO: a keyword-rich headline; the primary keyword in the first sentence and inside H2 subheadings; a scannable, well-structured 600-900 word body. "
"AEO (Answer Engine Optimization): open with a bolded TL;DR that directly answers 'what happened' in fact-only sentences, "
"then a 'Key takeaways' bullet list and a short FAQ (Q&A) — so AI answer engines can extract clean, quotable answers. "
"Rewrite in your own words — never copy the source text verbatim. Always attribute the original outlet and link it. "
"Never invent numbers or quotes. Never include trade secrets: internal metrics/p-values, experiment phase numbers, "
"GPU/container/infra names, unpublished model hyperparameters, or quantum-internal details."
)
DEVTO_SYSTEM = (
"You are a developer-focused technical writer for VIDRAFT (비드래프트), a Korean Pre-AGI AI startup, "
"writing for the Dev.to engineering community. Turn Korean press coverage about VIDRAFT into a TECHNICAL "
"English article aimed at software/ML engineers: what the technology is, the approach at a conceptual level, "
"public benchmarks and results, and how developers can try or access it (Hugging Face, GitHub, OpenAI-compatible API) "
"when those channels are public. Use a developer-friendly structure: a TL;DR, ## section headings, bullet lists, and a short FAQ. "
"Include code or shell commands ONLY when they are public and safe (e.g., `pip install`, `huggingface-cli download`, a generic OpenAI-compatible `curl`). "
"Never invent numbers, code, endpoints, or model names. Never reveal trade secrets: internal metrics/p-values, experiment phase numbers, "
"GPU/container/infra names or IPs, unpublished hyperparameters (learning rate, batch size, steps, loss), quantum-internal details, "
"or unreleased partnerships/financials."
)
# ── 발행 슬롯 (KST) — 2026-07-23 회장님 dev.to 증량 지시: 6h 간격 4회 ──
# Dev.to는 매 슬롯 발행(무제한·독립 dedup) → 하루 최대 4건.
# Medium은 publish_medium 내부 23h 레이트리밋으로 자동 1건/일 유지(초과 슬롯 skip).
POST_HOURS = [8, 12, 16, 20]
def _next_slot_kst() -> datetime.datetime:
now = datetime.datetime.now(KST)
cands = [now.replace(hour=h, minute=0, second=0, microsecond=0) for h in POST_HOURS]
future = [c for c in cands if c > now]
return min(future) if future else (min(cands) + datetime.timedelta(days=1))
# 하위호환 alias — 기존 호출부(_set_next/scheduler_loop/startup)가 그대로 12h 슬롯 사용
_next_8am_kst = _next_slot_kst
def _set_next():
global next_gen_time
next_gen_time = _next_8am_kst().strftime("%Y-%m-%d %H:%M KST")
def fetch_homepage_news() -> list:
"""VIDraft 홈페이지 news.json → 기사 리스트"""
try:
r = requests.get(NEWS_JSON_URL, timeout=15, headers={"Cache-Control": "no-cache"})
r.raise_for_status()
return r.json().get("items", [])
except Exception as e:
print(f" ✗ news.json 로드 실패: {e}")
return []
def load_posted_urls() -> set:
"""이미 Medium 발행한 원문 URL 집합 (dataset/posted_news.json)"""
try:
p = hf_hub_download(DATASET_REPO, POSTED_LOG_PATH, repo_type="dataset",
token=HF_TOKEN, force_download=True)
return set(json.load(open(p, encoding="utf-8")).get("urls", []))
except Exception:
return set()
def _posted_today() -> bool:
try:
p = hf_hub_download(DATASET_REPO, POSTED_LOG_PATH, repo_type="dataset",
token=HF_TOKEN, force_download=True)
last_at = json.load(open(p, encoding="utf-8")).get("last", {}).get("at", "")
return last_at.startswith(datetime.datetime.now(KST).strftime("%Y-%m-%d"))
except Exception:
return False
def _posted_since(hours: float) -> bool:
"""최근 `hours` 시간 내 발행 이력이 있으면 True (12h 캐치업 중복방지)"""
try:
p = hf_hub_download(DATASET_REPO, POSTED_LOG_PATH, repo_type="dataset",
token=HF_TOKEN, force_download=True)
last_at = json.load(open(p, encoding="utf-8")).get("last", {}).get("at", "")
dt = datetime.datetime.strptime(last_at[:16], "%Y-%m-%d %H:%M").replace(tzinfo=KST)
return (datetime.datetime.now(KST) - dt).total_seconds() < hours * 3600
except Exception:
return False
def mark_posted(url: str, title: str, medium_url: str):
urls = list(load_posted_urls())
if url and url not in urls:
urls.append(url)
payload = {"urls": urls, "last": {"src": url, "title": title, "medium": medium_url,
"at": datetime.datetime.now(KST).strftime("%Y-%m-%d %H:%M KST")}}
try:
HfApi(token=HF_TOKEN).upload_file(
path_or_fileobj=json.dumps(payload, ensure_ascii=False, indent=1).encode("utf-8"),
path_in_repo=POSTED_LOG_PATH, repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"posted: {title[:40]}")
except Exception as e:
print(f" ✗ posted_news.json 저장 실패: {e}")
def load_posted_devto() -> set:
"""이미 Dev.to 발행한 원문 URL 집합 (dataset/posted_devto.json)"""
try:
p = hf_hub_download(DATASET_REPO, POSTED_DEVTO_PATH, repo_type="dataset",
token=HF_TOKEN, force_download=True)
return set(json.load(open(p, encoding="utf-8")).get("urls", []))
except Exception:
return set()
def mark_posted_devto(url: str, title: str, devto_url: str):
urls = list(load_posted_devto())
if url and url not in urls:
urls.append(url)
payload = {"urls": urls, "last": {"src": url, "title": title, "devto": devto_url,
"at": datetime.datetime.now(KST).strftime("%Y-%m-%d %H:%M KST")}}
try:
HfApi(token=HF_TOKEN).upload_file(
path_or_fileobj=json.dumps(payload, ensure_ascii=False, indent=1).encode("utf-8"),
path_in_repo=POSTED_DEVTO_PATH, repo_id=DATASET_REPO, repo_type="dataset",
commit_message=f"devto posted: {title[:40]}")
except Exception as e:
print(f" ✗ posted_devto.json 저장 실패: {e}")
def _dparse(s: str):
try:
return datetime.datetime.strptime(s[:10].replace(".", "-"), "%Y-%m-%d").date()
except Exception:
return None
def pick_target_article(items: list, posted: set):
"""미발행 1건 선택: 최신뉴스(D0/D-1/D-2) 우선, 없으면 '최신 미발행 기사(날짜 무관)'로 폴백.
→ 새 보도자료가 없어도 아카이브의 미발행분을 계속 게재해 12h 자동발행이 끊기지 않음 (창업자 브런치 제외)."""
today = datetime.datetime.now(KST).date()
fresh = []; older = []
for it in items:
u = it.get("u", "")
if not u or u in posted:
continue
s = it.get("s", "")
if "브런치" in s or "brunch" in s.lower():
continue
d = _dparse(it.get("d", ""))
if d is None:
older.append((datetime.date(2000, 1, 1), it)); continue
delta = (today - d).days
if delta in (0, 1, 2):
fresh.append((delta, it))
else:
older.append((d, it))
if fresh:
fresh.sort(key=lambda x: x[0]) # D0 → D-1 → D-2
return fresh[0][1]
if older:
older.sort(key=lambda x: x[0], reverse=True) # 폴백: 최신 미발행부터
return older[0][1]
return None
def _strip_html(h: str) -> str:
import re
import html as _html
h = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', h)
h = re.sub(r'(?s)<[^>]+>', ' ', h)
return re.sub(r'\s+', ' ', _html.unescape(h)).strip()
def fetch_article_body(url: str) -> str:
try:
raw = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}).content
page = None
for enc in ("utf-8", "euc-kr", "cp949"):
try:
d = raw.decode(enc)
if sum(0xAC00 <= ord(c) <= 0xD7A3 for c in d) > 20:
page = d
break
except Exception:
pass
if page is None:
page = raw.decode("utf-8", "replace")
return _strip_html(page)[:4500]
except Exception as e:
print(f" ✗ 본문 로드 실패: {e}")
return ""
def translate_news_to_english(item: dict):
"""뉴스 1건 → 영어 브리프 markdown + seo (출처 명시, 이미지 없음)"""
if not ANTHROPIC_API_KEY:
print(" ✗ ANTHROPIC_API_KEY 없음")
return None
title = item.get("t", "")
src = item.get("s", "")
date = item.get("d", "")
url = item.get("u", "")
body = fetch_article_body(url)
prompt = f"""Write an SEO- and AEO-optimized English news article for Medium based on this Korean press article about VIDRAFT.
SOURCE OUTLET: {src}
DATE: {date}
ORIGINAL URL: {url}
KOREAN HEADLINE: {title}
KOREAN BODY (may be partial): {body[:3500]}
Produce MARKDOWN in EXACTLY this structure:
# <SEO headline: compelling, contains the primary keyword, ~60 chars>
*<one-line subtitle stating why this matters>*
> **TL;DR:** <2-3 fact-only sentences that directly answer "what happened", front-loading the primary keyword. Answer engines will quote this.>
<Opening paragraph — put the primary keyword in the very first sentence; state who/what/when.>
## What VIDRAFT announced
<facts from the source only>
## Why it matters
<context and significance; no invented numbers>
## Key takeaways
- <fact 1>
- <fact 2>
- <fact 3>
## Frequently asked questions
**Q: <a question a reader or search engine would ask>?**
A: <clear, factual 1-2 sentence answer.>
**Q: <second question>?**
A: <answer.>
**Q: <third question>?**
A: <answer.>
---
*Source: {src} ({date}) — [original article]({url})*
Rules:
- 600-900 words, factual professional tech-news tone. Rewrite in your OWN words (never verbatim).
- Use ONLY facts present in the source. Do not invent numbers or quotes.
- Primary keyword = "VIDRAFT", the product name, or "Korean AI startup" — used naturally, never stuffed.
- Keep the exact section headings above (H2 ##). No images/placeholders. No trade secrets.
- The FINAL line MUST be exactly: *Source: {src} ({date}) — [original article]({url})*"""
try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
msg = client.messages.create(
model="claude-sonnet-4-6", max_tokens=2000,
system=ENGLISH_SYSTEM, messages=[{"role": "user", "content": prompt}])
md = msg.content[0].text.strip()
except Exception as e:
print(f" ✗ 영어 생성 실패: {e}")
return None
low = md.lower()
for kw in ["mcnemar", "ibm qpu", "kingston", "predecoder", "learning rate",
"batch size", "container id", "job id"]:
if kw in low:
print(f" ⛔ 기밀 키워드 감지({kw}) → 스킵")
return None
if url and url.lower() not in low:
md += f"\n\n*Source: {src} ({date}) — [original article]({url})*"
lines = md.split("\n")
en_title = lines[0].lstrip("# ").strip() if lines and lines[0].strip() else title
# ── 실제 SEO/AEO 메타데이터 생성 (제목·메타디스크립션·검색 태그) ──
try:
seo = generate_seo_metadata(md) or {}
except Exception as e:
print(f" seo meta fail: {e}"); seo = {}
# 영어 Medium 게시 제목은 항상 영어 H1로 강제(generate_seo_metadata의 한국어 제목 덮어씀)
seo["seo_title"] = en_title[:250]
if not seo.get("tags_en"):
seo["tags_en"] = ["Artificial Intelligence", "Machine Learning", "Korea", "Startup", "VIDRAFT"]
seo["tags_en"] = [str(t).strip() for t in seo["tags_en"] if str(t).strip()][:5]
return md, seo
def translate_news_to_devto(item: dict):
"""뉴스 1건 → Dev.to 기술 심화 영어 기사 markdown + seo (개발자 대상 · 영업기밀 차단)"""
if not ANTHROPIC_API_KEY:
print(" ✗ ANTHROPIC_API_KEY 없음"); return None
title = item.get("t", ""); src = item.get("s", ""); date = item.get("d", ""); url = item.get("u", "")
body = fetch_article_body(url)
prompt = f"""Write a TECHNICAL English article for the Dev.to developer community based on this Korean press article about VIDRAFT.
SOURCE OUTLET: {src}
DATE: {date}
ORIGINAL URL: {url}
KOREAN HEADLINE: {title}
KOREAN BODY (may be partial): {body[:3500]}
Produce MARKDOWN in EXACTLY this structure:
# <technical, keyword-rich headline aimed at engineers>
> **TL;DR:** <2-3 sentences: what it is, what it does technically, why developers should care.>
## What it is
<concise technical description; facts from the source only>
## How it works
<conceptual, high-level mechanism ONLY — no secret internals, no invented details>
## Benchmarks & results
<public numbers from the source only; if none are given, describe qualitatively — never invent>
## How to try it
<how developers can access it (Hugging Face / GitHub / OpenAI-compatible API) IF that is public in the source; include only safe public commands. If access is not public, say so plainly.>
## FAQ
**Q: <a question an engineer would actually ask>?**
A: <clear, factual answer.>
**Q: <second question>?**
A: <answer.>
---
*Originally reported by {src} ({date}) — [source article]({url}).*
Rules:
- Developer tone, 600-900 words. Facts only — do NOT invent numbers, code, endpoints, or model names.
- No trade secrets (internal metrics, experiment phase numbers, GPU/infra names, hyperparameters, quantum internals).
- English. Keep the exact H2 headings above."""
try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
msg = client.messages.create(model="claude-sonnet-4-6", max_tokens=2200,
system=DEVTO_SYSTEM, messages=[{"role": "user", "content": prompt}])
md = msg.content[0].text.strip()
except Exception as e:
print(f" ✗ Dev.to 생성 실패: {e}"); return None
low = md.lower()
for kw in ["mcnemar", "ibm qpu", "kingston", "predecoder", "learning rate",
"batch size", "container id", "job id"]:
if kw in low:
print(f" ⛔ 기밀 키워드 감지({kw}) → 스킵"); return None
if url and url.lower() not in low:
md += f"\n\n*Originally reported by {src} ({date}) — [source article]({url}).*"
lines = md.split("\n")
en_title = lines[0].lstrip("# ").strip() if lines and lines[0].strip() else title
try:
seo = generate_seo_metadata(md) or {}
except Exception:
seo = {}
seo["seo_title"] = en_title[:250] # Dev.to 게시 제목 = 영어 H1 강제
if not seo.get("tags_en"):
seo["tags_en"] = ["ai", "machinelearning", "opensource", "llm"]
return md, seo
def _run_medium(items: list):
"""Medium 뉴스 브리프 1건 (SEO/AEO 영문)"""
global articles_cache, last_generated
posted = load_posted_urls()
target = pick_target_article(items, posted)
if not target:
print(" [Medium] D0/D-1/D-2 미발행 뉴스 없음 → 스킵"); return
print(f" [Medium] 대상: [{target.get('s')}] {target.get('d')}{target.get('t','')[:40]}")
res = translate_news_to_english(target)
if not res:
print(" [Medium] 생성 실패/기밀차단 → 스킵"); return
md, seo = res
r = publish_medium(md, seo)
print(f" [Medium] {r.get('status')} {r.get('url','')}")
if r.get("status") == "ok":
mark_posted(target.get("u", ""), target.get("t", ""), r.get("url", ""))
try:
aid = datetime.datetime.now(KST).strftime("%Y-%m-%d_%H%M")
articles_cache = save_to_dataset(aid, md, [], seo, [r])
except Exception as e:
print(f" archive save: {e}")
last_generated = datetime.datetime.now(KST).strftime("%Y-%m-%d %H:%M KST")
def _run_devto(items: list):
"""Dev.to 기술 심화 1건 (개발자 대상 영문 · 영업기밀 차단 · 독립 dedup)"""
global last_devto_result
if not DEVTO_API_KEY:
last_devto_result = "skip: DEVTO_API_KEY 미설정"; print(" [Dev.to] "+last_devto_result); return
posted = load_posted_devto()
target = pick_target_article(items, posted)
if not target:
last_devto_result = "skip: D0/D-1/D-2 미발행 뉴스 없음"; print(" [Dev.to] "+last_devto_result); return
print(f" [Dev.to] 대상: [{target.get('s')}] {target.get('d')}{target.get('t','')[:40]}")
res = translate_news_to_devto(target)
if not res:
last_devto_result = "skip: 생성 실패/기밀차단"; print(" [Dev.to] "+last_devto_result); return
md, seo = res
r = publish_devto(md, seo)
last_devto_result = f"status={r.get('status')} code={r.get('code','')} reason={str(r.get('reason',''))[:140]} url={r.get('url','')}"
print(f" [Dev.to] {last_devto_result}")
if r.get("status") == "ok":
mark_posted_devto(target.get("u", ""), target.get("t", ""), r.get("url", ""))
def run_generation():
"""12시간마다 Medium(뉴스 브리프) + Dev.to(기술 심화) 각 1건 신규 발행"""
global is_generating
if is_generating:
return
is_generating = True
print(f"\n{'='*50}")
print(f"[{datetime.datetime.now(KST):%Y-%m-%d %H:%M KST}] 12h 자동 발행 시작 (Medium + Dev.to)")
print(f"{'='*50}")
try:
items = fetch_homepage_news()
print(f"① 홈페이지 뉴스 {len(items)}건 로드")
_run_medium(items)
_run_devto(items)
except Exception as e:
import traceback
print(f"❌ 실패: {e}")
traceback.print_exc()
finally:
is_generating = False
_set_next()
GENERATION_INTERVAL_HOURS = 24 # 하루 1회 (Medium 일별 한도 대응 — 회장님 지시 2026-07-08)
STATS_INTERVAL_HOURS = 6 # 6시간마다 통계 갱신
def stats_loop():
"""6시간마다 통계 자동 갱신"""
time.sleep(3600) # 첫 글 생성 후 1시간 대기
while True:
print("\n📊 통계 갱신 시작...")
collect_all_stats()
time.sleep(STATS_INTERVAL_HOURS * 3600)
def scheduler_loop():
"""12시간마다(08:00·20:00 KST) 발행 (부팅 시 최근 11h 내 미발행이면 캐치업 1회)"""
if not _posted_since(11):
print("⏰ 부팅 캐치업: 최근 11h 내 미발행 → 1회 실행")
run_generation()
while True:
nxt = _next_slot_kst()
wait = (nxt - datetime.datetime.now(KST)).total_seconds()
print(f"⏰ 다음 발행 예약: {nxt:%Y-%m-%d %H:%M KST} ({wait/3600:.1f}h 후)")
time.sleep(max(60, wait))
run_generation()
# ═══════════════════════════════════════════════════════════════════
# 5.5 Threads 자동 게시 루틴 — 한국어·담백한 전문가·#aithreads (2026-07-13)
# ═══════════════════════════════════════════════════════════════════
# 🔴 2026-07-20: 과다 게시로 계정 일시 잠김 → 전면 중지했다가, 2026-07-21 회장님 지시로 재개.
# 규칙 v5: 09~19시 2시간 앵커 5개 + 각 11~111분 랜덤 지터 = 하루 5건
# 구성: 홈피 최신 보도 1 + 블로그 1 + 연관(논문/뉴스/모델) 3
# 재개하려면 THREADS_ENABLED=True (또는 Space 변수 THREADS_ENABLED=1). 재개 시에도 최소치로만 운영할 것.
THREADS_ENABLED = os.environ.get("THREADS_ENABLED", "0") == "1" # 기본 OFF (하드 킬스위치)
THREADS_INTERVAL_MIN = 60 # 앵커 간격(분) — 1시간 (10건/일)
THREADS_START, THREADS_END = "09:00", "19:00" # 게시 시간대 (09~19시)
THREADS_PER_DAY = 10 # 하루 10건 (회장님 2026-07-23)
THREADS_JITTER_MIN, THREADS_JITTER_MAX = 5, 40 # 앵커 이후 랜덤 지터(분) — 60분 간격 내 유지
SCHED_VERSION = 7 # v7: 영/한 이중 게시(2026-07-26). 올리면 부팅 시 오늘 큐를 새 규칙으로 재편성
def _threads_slots():
"""09~19시 2시간 앵커에서 5개를 고르고, 각 앵커에 11~111분 랜덤 지터를 더한다.
지터가 겹치거나 역순이 되지 않도록 정렬 후 최소 30분 간격을 보장한다."""
import random as _rnd
st = datetime.datetime.strptime(THREADS_START, "%H:%M")
en = datetime.datetime.strptime(THREADS_END, "%H:%M")
anchors = []
cur = st
while cur <= en:
anchors.append(cur); cur += datetime.timedelta(minutes=THREADS_INTERVAL_MIN)
anchors = anchors[:THREADS_PER_DAY] # THREADS_PER_DAY개로 제한 (60분·09~19시 → 10개)
out, last = [], None
for a in anchors:
t = a + datetime.timedelta(minutes=_rnd.randint(THREADS_JITTER_MIN, THREADS_JITTER_MAX))
if t > en:
t = en
if last and (t - last).total_seconds() < 1800: # 최소 30분 간격
t = last + datetime.timedelta(minutes=30)
if t > en:
t = en
out.append(t.strftime("%H:%M")); last = t
return out
THREADS_SIG = "— 비드래프트 · vidraft.net"
THREADS_SIG_EN = "— VIDRAFT · vidraft.net"
THREADS_STATE_FILE = "threads_queue.json"
threads_queue: list = []
_threads_curated_date = ""
_threads_report_date = ""
last_threads_report = ""
_vidraft_idx = 0
VIDRAFT_ASSETS = [
("비드래프트", "GPU 없이 340억 파라미터 모델을 CPU로 서빙(VKUE)", "34.7B 모델이 토큰당 ~3B만 쓰는 희소(MoE) 구조라 노트북·CPU에서도 구동. 데모에서 GPU와 CPU 속도를 직접 비교하도록 공개.", "https://huggingface.co/spaces/FINAL-Bench/Ourbox-35B-VKUE-Demo"),
("비드래프트", "학습 없이 모델을 교배하는 Darwin — 누적 100만 다운로드", "서로 다른 모델의 강점을 병합해 다음 세대를 만드는 진화형 접근. 방법은 arXiv에 공개.", "https://arxiv.org/abs/2605.14386"),
("비드래프트", "리만 가설의 최신 도구, 수치로 처음 구현", "도쿄과학원 스즈키 교수가 이론으로만 제시한 연산자를 블랙웰 GPU·AI로 구현하고 스펙트럼을 특성화. 증명이 아니라 도구의 정직한 수치 특성화.", "https://vidraft-zeta-zeros.static.hf.space"),
("비드래프트", "GPU 1장으로 초당 18,057토큰 — VKIE 통합 추론 엔진", "VKAE의 속도와 VKUE의 절감을 합쳐 단일 B200에서 동시 서빙 처리량을 극대화. 같은 34.7B 모델이 데이터센터부터 무료 CPU까지 품질 그대로. 모든 수치 실측.", "https://huggingface.co/spaces/FINAL-Bench/VKIE"),
("비드래프트", "신약 후보 예측, 글로벌 벤치마크 Polaris 14개 부문 1위", "약효·독성·용해도·항암표적 등 14개 부문에서 1위. 실험할 가치가 높은 후보를 먼저 골라주는 AI 연구 인프라(PharmaOS).", "https://huggingface.co/spaces/VIDraft/PharmaOS"),
("비드래프트", "메타인지로 스스로 오류를 검증하는 AETHER", "복수 어텐션 위에 실행·비평·통합·리서치·창발 5개 에이전트 루프를 얹어 환각 가능성을 내부에서 교차검증하는 독자 아키텍처.", "https://vidraft.net"),
]
def _tg_send(text: str) -> bool:
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHANNEL_ID:
return False
try:
r = requests.post("https://api.telegram.org/bot%s/sendMessage" % TELEGRAM_BOT_TOKEN,
json={"chat_id": TELEGRAM_CHANNEL_ID, "text": text, "disable_web_page_preview": True}, timeout=20)
return r.status_code == 200
except Exception:
return False
def _threads_save_state():
try:
import io, json as _j
data = _j.dumps({"version": SCHED_VERSION, "date": _threads_curated_date, "queue": threads_queue}, ensure_ascii=False).encode("utf-8")
HfApi(token=HF_TOKEN).upload_file(path_or_fileobj=io.BytesIO(data), path_in_repo=THREADS_STATE_FILE,
repo_id=DATASET_REPO, repo_type="dataset", commit_message="threads queue state")
except Exception as e:
print(" [threads] save fail:", e)
def _threads_load_state():
global threads_queue, _threads_curated_date
try:
p = hf_hub_download(DATASET_REPO, THREADS_STATE_FILE, repo_type="dataset", token=HF_TOKEN, force_download=True)
import json as _j
d = _j.load(open(p, encoding="utf-8"))
threads_queue = d.get("queue", []); _threads_curated_date = d.get("date", "")
if d.get("version") != SCHED_VERSION:
_threads_curated_date = "" # 스케줄 버전 변경 → 오늘 새 간격으로 즉시 재편성
print(" [threads] state loaded: %s, %d items (v=%s)" % (_threads_curated_date, len(threads_queue), d.get("version")))
except Exception as e:
print(" [threads] no prior state:", e)
def _hget(u, t=20):
import urllib.request as _u, json as _j
return _j.loads(_u.urlopen(_u.Request(u, headers={"User-Agent": "Mozilla/5.0"}), timeout=t).read().decode("utf-8", "ignore"))
# 실측 기반(2026-07-17 좋아요 분석): 광범위·교양·정리류 논문이 니치 벤치마크보다 참여 훨씬 높음.
_PAPER_BROAD = ("survey", "tutorial", "overview", "review", "introduction", "principles", "foundations",
"textbook", "guide", "lessons", "a study of", "understanding", "rethinking",
"what makes", "why ", "how ", "the math", "mathematics", "fundamental")
_PAPER_NICHE = ("uav", "drone", "facial expression", "micro-expression", "emotion recognition",
"photorealistic simulator", "self in space", "benchmark for", "a benchmark",
"dataset for", " for autonomous driving simulation", "affect")
def _paper_score(title):
t = (title or "").lower()
s = 0
for k in _PAPER_BROAD:
if k in t: s += 2
for k in _PAPER_NICHE:
if k in t: s -= 3
return s
def _threads_fetch_items():
global _vidraft_idx
items = []
# 논문: 후보를 넓게 받아 대중성 점수로 선별(교양·정리류 우선, 니치 감점) — 상위 10
try:
cands = []
for p in _hget("https://huggingface.co/api/daily_papers?limit=30"):
pp = p.get("paper", {}); ti = pp.get("title", "")
if ti: cands.append((_paper_score(ti), ti, "huggingface.co/papers/" + pp.get("id", "")))
cands.sort(key=lambda x: x[0], reverse=True) # 대중성 높은 순 (동점은 트렌딩 순 유지)
for _sc, ti, url in cands[:10]:
items.append(("논문", "HF 데일리페이퍼", ti, url))
except Exception: pass
# 해커뉴스: 실측 강세(최대 ♥64) — 14
try:
for i in _hget("https://hacker-news.firebaseio.com/v0/topstories.json")[:14]:
it = _hget("https://hacker-news.firebaseio.com/v0/item/%d.json" % i)
items.append(("해커뉴스", "해커뉴스 화제", it.get("title", ""), it.get("url", "") or ("news.ycombinator.com/item?id=%d" % i)))
except Exception: pass
# HF 스페이스/모델/데이터셋: 실측 최약(♥2.6~3.4) — 각 3으로 축소
for api, cat, label, pre in [("spaces", "스페이스", "HF 스페이스", "huggingface.co/spaces/"),
("models", "모델", "HF 트렌딩 모델", "huggingface.co/"),
("datasets", "데이터셋", "HF 데이터셋", "huggingface.co/datasets/")]:
try:
r = _hget("https://huggingface.co/api/%s?sort=trendingScore&direction=-1&limit=4" % api)
for m in r[:3]:
items.append((cat, label, m.get("id", ""), pre + m.get("id", "")))
except Exception: pass
# 자사 소식(폴백용): 실측 강세(♥5.2)
for _ in range(3):
a = VIDRAFT_ASSETS[_vidraft_idx % len(VIDRAFT_ASSETS)]; _vidraft_idx += 1
items.append((a[0], a[1], a[2], a[3]))
return items
def _fetch_press_item():
"""홈피 news.json 최신 보도 1건."""
try:
r = requests.get("https://vidraft.net/news.json", timeout=20,
headers={"Cache-Control": "no-cache"})
arr = (r.json() or {}).get("items", [])
for it in arr:
t, u, src = it.get("t", ""), it.get("u", ""), it.get("s", "")
if t and u:
return ("보도", "언론 보도 · %s" % src, t, u)
except Exception as e:
print(" [threads] press fetch fail:", e)
return None
def _fetch_blog_item():
"""블로그(브런치다이어리 아카이브 / 티스토리) 최신 1건."""
try:
p = hf_hub_download(DATASET_REPO, "index.json", repo_type="dataset", token=HF_TOKEN,
force_download=True)
import json as _j
arr = _j.load(open(p, encoding="utf-8"))
if isinstance(arr, dict):
arr = arr.get("items", [])
for it in sorted(arr, key=lambda x: str(x.get("date", "")), reverse=True):
t = it.get("title", ""); u = it.get("url", "") or it.get("devto_url", "") or it.get("medium_url", "")
if t and u:
return ("블로그", "비드래프트 블로그", t, u)
except Exception as e:
print(" [threads] blog fetch fail:", e)
return ("블로그", "비드래프트 블로그", "오픈 웨이트 ≠ 오픈소스 — 완전공개 소버린 AI가 무엇인지",
"https://livegpt.tistory.com/9")
def _threads_compose_day():
"""하루치 THREADS_PER_DAY건 구성: 보도 1 + 블로그 1 + 연관(논문/뉴스/모델)로 채움."""
picked = []
p = _fetch_press_item()
if p:
picked.append(p)
b = _fetch_blog_item()
if b:
picked.append(b)
pool = _threads_fetch_items()
order = ["논문", "해커뉴스", "모델", "스페이스", "데이터셋", "비드래프트"]
need = THREADS_PER_DAY - len(picked)
used_cat = {}
for want in order:
if need <= 0:
break
for it in pool:
if it[0] != want or it in picked:
continue
if used_cat.get(want, 0) >= 1 and need > 1: # 카테고리 다양성 우선
break
picked.append(it); used_cat[want] = used_cat.get(want, 0) + 1
need -= 1
break
for it in pool: # 그래도 모자라면 채움
if need <= 0:
break
if it not in picked:
picked.append(it); need -= 1
return picked[:THREADS_PER_DAY]
def _threads_tmpl(cat, label, title, url):
return "%s는 다음 소식을 전했습니다 — %s\n\n출처: %s\n%s" % (label, title, url, THREADS_SIG)
def _threads_tmpl_en(cat, label, title, url):
return "%s reports: %s\n\nSource: %s\n%s" % (label, title, url, THREADS_SIG_EN)
def _threads_gen_chunk(items):
"""각 항목을 한국어·영어 두 버전으로 생성. 반환: [{cat, text(ko), text_en(en)}]"""
try:
import json as _j
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
spec = _j.dumps([{"cat": c, "label": l, "title": t, "url": u} for (c, l, t, u) in items], ensure_ascii=False)
prompt = ("너는 한국어·영어 이중언어 AI 인사이트 채널의 에디터다. 아래 각 항목으로 Threads 게시물을 "
"한국어 1개 + 영어 1개, 총 두 버전을 작성하라. 두 버전은 같은 사실을 각 언어의 자연스러운 문체로 옮긴 것이어야 한다(직역 금지).\n"
"★구조 규칙(가장 중요) — '인물·기업 주도 발표체'로 써라:\n"
" 1) 도입: [주체(인물·기업·연구진)와 직함/소속]가 [핵심 주장·발견]을 밝혔다는 한 문장으로 시작한다. 핵심 표현은 큰따옴표로 인용한다. "
"(한국어 예: '비드래프트는 ~을 공개했습니다.' / 영어 예: 'OpenAI CEO Sam Altman said that ~.')\n"
" 2) 본문: 1~2개의 짧은 문단으로 그 주장의 근거와 맥락을 풀되, 가능하면 직접 인용을 한 번 넣는다.\n"
" 3) 마무리: 그것이 무엇을 의미하는지 한 문장으로 정리한다 (한국어: '~을 시사합니다' 등 / 영어: 'The move signals ~.' 등).\n"
"★톤: 3인칭 정보 전달체. 한국어는 합니다체. 영어는 담백한 뉴스 문체(announcer voice). 이모지·해시태그·감탄사·홍보 문구는 절대 쓰지 말 것. 사실만 쓰고, 없는 수치·인용은 지어내지 말 것.\n"
"★길이: 한국어 200~400자, 영어 350~600자. 단일 토픽. 하나의 완결된 뉴스처럼.\n"
"★비드래프트/vidraft 항목: 3인칭으로 담담히 소개하되 과장·미검증 주장 금지. 내부 기술 상세(양자화 레시피 등)나 지어낸 수치 금지.\n"
"형식: 각 항목마다 아래 정확한 형식으로 출력한다.\n"
" [KO]\n (한국어 본문)\n 빈 줄\n 출처: {url}\n " + THREADS_SIG + "\n"
" ~~~\n"
" [EN]\n (영어 본문)\n 빈 줄\n Source: {url}\n " + THREADS_SIG_EN + "\n"
"항목과 항목 사이는 === 한 줄로만 구분. 다른 설명 절대 금지.\n\n"
"항목(JSON): " + spec)
msg = client.messages.create(model="claude-sonnet-4-6", max_tokens=8000,
messages=[{"role": "user", "content": prompt}])
raw = "".join(getattr(b, "text", "") for b in msg.content).strip()
parts = [p.strip() for p in raw.split("===") if p.strip()]
if len(parts) >= max(1, len(items) - 1):
posts = []
for i, (c, l, t, u) in enumerate(items):
ko = en = ""
if i < len(parts):
seg = parts[i].split("~~~")
ko = seg[0].strip()
en = seg[1].strip() if len(seg) > 1 else ""
# [KO]/[EN] 라벨 제거
for lab in ("[KO]", "[EN]", "KO:", "EN:"):
ko = ko.replace(lab, "").strip()
en = en.replace(lab, "").strip()
if not ko: ko = _threads_tmpl(c, l, t, u)
if not en: en = _threads_tmpl_en(c, l, t, u)
if THREADS_SIG not in ko and "비드래프트" not in ko: ko += "\n" + THREADS_SIG
if THREADS_SIG_EN not in en and "VIDRAFT" not in en: en += "\n" + THREADS_SIG_EN
posts.append({"cat": c, "text": ko[:495], "text_en": en[:495]})
return posts
except Exception as e:
print(" [threads] claude gen fail:", e)
return [{"cat": c, "text": _threads_tmpl(c, l, t, u)[:495], "text_en": _threads_tmpl_en(c, l, t, u)[:495]}
for (c, l, t, u) in items]
def _threads_make_posts(items):
"""항목을 10개씩 배치로 나눠 생성 — 30건에서도 품질·토큰 안정."""
posts = []
for k in range(0, len(items), 10):
posts.extend(_threads_gen_chunk(items[k:k + 10]))
return posts
def _threads_curate():
global threads_queue, _threads_curated_date
now = datetime.datetime.now(KST); today = now.strftime("%Y-%m-%d")
if not THREADS_ENABLED: # 킬스위치: 큐 편성 자체를 막음
threads_queue = []; _threads_curated_date = today
print(" [threads] DISABLED — 큐레이션 skip")
return threads_queue
posts = _threads_make_posts(_threads_compose_day())
# 영/한 각각 등록(회장님 2026-07-26): 각 토픽을 한국어→영어 두 게시물로 펼친다.
# 일일 총량은 슬롯(THREADS_PER_DAY)으로 캡되므로, 결과적으로 하루 (슬롯/2)개 토픽 × 2언어.
expanded = []
for p in posts:
expanded.append({"text": p["text"], "cat": p["cat"], "lang": "ko"})
if p.get("text_en"):
expanded.append({"text": p["text_en"], "cat": p["cat"], "lang": "en"})
slots = [s for s in _threads_slots() if s >= now.strftime("%H:%M")]
n = min(len(slots), len(expanded)) # 남은 실제 슬롯만큼만 편성 (자정 넘김 방지)
expanded, slots = expanded[:n], slots[:n]
threads_queue = [{"time": s, "text": e["text"], "cat": e["cat"], "lang": e["lang"],
"status": "pending", "result": None}
for e, s in zip(expanded, slots)]
_threads_curated_date = today; _threads_save_state()
ko_n = sum(1 for x in threads_queue if x.get("lang") == "ko")
print(" [threads] curated %d posts for %s (ko=%d, en=%d)" % (len(threads_queue), today, ko_n, len(threads_queue) - ko_n))
return threads_queue
def _threads_daily_report():
global last_threads_report
now = datetime.datetime.now(KST)
posted = [x for x in threads_queue if x.get("status") == "posted"]
pend = [x for x in threads_queue if x.get("status") == "pending"]
err = [x for x in threads_queue if x.get("status") in ("error", "missed")]
nxt = min([x["time"] for x in pend]) if pend else "-"
rep = "\n".join([
"📊 비드래프트 자동화 일일보고 (%s)" % now.strftime("%m-%d %H:%M KST"), "",
"🧵 Threads (@kimminsik1116)",
" · 오늘 큐 %d건 · 게시완료 %d · 대기 %d · 실패 %d" % (len(threads_queue), len(posted), len(pend), len(err)),
" · 다음 예정 %s" % nxt, "",
"📝 Medium / 🔧 Dev.to",
" · Medium 최근: %s" % (last_generated or "-"),
" · Dev.to 최근: %s" % (last_devto_result or "-"),
])
last_threads_report = rep
print(" [threads] daily report (tg_sent=%s)" % _tg_send(rep))
return rep
def threads_loop():
global _threads_report_date
if not THREADS_ENABLED:
print(" [threads] DISABLED (THREADS_ENABLED=0) — 자동 게시 중지, 루프 종료")
return
_threads_load_state()
while True:
try:
now = datetime.datetime.now(KST); today = now.strftime("%Y-%m-%d"); hm = now.strftime("%H:%M")
if _threads_curated_date != today and now.hour >= 7:
_threads_curate()
if _threads_report_date != today and now.hour == 8:
_threads_daily_report(); _threads_report_date = today
for it in threads_queue:
if it.get("status") != "pending" or it["time"] > hm:
continue
try:
slot_dt = now.replace(hour=int(it["time"][:2]), minute=int(it["time"][3:]), second=0, microsecond=0)
behind = (now - slot_dt).total_seconds()
except Exception:
behind = 0
if behind > 1800:
it["status"] = "missed"; _threads_save_state(); continue
r = publish_threads(it["text"], {"threads_text": it["text"]})
it["status"] = "posted" if r.get("status") == "ok" else "error"
it["result"] = {"url": r.get("url", ""), "status": r.get("status"), "reason": str(r.get("reason", ""))[:120]}
_threads_save_state()
break
except Exception as e:
print(" [threads] loop err:", e)
time.sleep(60)
# ═══════════════════════════════════════════════════════════════════
# 5.7 알림 푸시 데일리 루틴 — 매일 10:00 · 17:00 KST (회장님 지시 2026-07-17)
# 홈피 news.json 신규 소식 → 알림 문안 자동 생성 → VIDraft/push-api 발송
# ═══════════════════════════════════════════════════════════════════
PUSH_API_BASE = "https://vidraft-push-api.hf.space"
PUSH_ADMIN_KEY = os.environ.get("PUSH_ADMIN_KEY", "")
PUSH_SLOTS = ["10:00", "17:00"]
PUSH_STATE_FILE = "push_sent.json"
last_push_result = ""
_push_done = set() # "YYYY-MM-DD HH:MM" 슬롯 중복 방지
def _push_load_sent() -> set:
try:
p = hf_hub_download(DATASET_REPO, PUSH_STATE_FILE, repo_type="dataset",
token=HF_TOKEN, force_download=True)
return set(json.load(open(p, encoding="utf-8")).get("urls", []))
except Exception:
return set()
def _push_save_sent(urls: set):
try:
data = json.dumps({"urls": sorted(urls)[-300:],
"at": datetime.datetime.now(KST).strftime("%Y-%m-%d %H:%M KST")},
ensure_ascii=False).encode("utf-8")
HfApi(token=HF_TOKEN).upload_file(path_or_fileobj=io.BytesIO(data), path_in_repo=PUSH_STATE_FILE,
repo_id=DATASET_REPO, repo_type="dataset", commit_message="push sent state")
except Exception as e:
print(" [push] state save fail:", e)
def _push_pick() -> dict:
"""홈피 news.json에서 아직 푸시하지 않은 최신 1건."""
items = fetch_homepage_news()
if not items:
return {}
sent = _push_load_sent()
fresh = [a for a in items if a.get("u") and a["u"] not in sent]
if not fresh:
return {}
fresh.sort(key=lambda a: str(a.get("d", "")), reverse=True)
return fresh[0]
def _push_compose(item: dict) -> dict:
"""뉴스 1건 → 알림 제목/본문. 담백한 전문가 톤. 실패 시 템플릿 폴백."""
src = item.get("s", "")
title = item.get("t", "")
url = item.get("u", "https://vidraft.net")
fallback = {"title": "비드래프트 새 소식",
"body": ("%s — %s" % (src, title))[:110],
"url": url}
if not ANTHROPIC_API_KEY:
return fallback
try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
rules = [
"아래 비드래프트 관련 보도로 웹 푸시 알림 문안을 만들어라.",
"규칙: 제목 24자 이내, 본문 60자 이내. 담백한 전문가 톤. 과장·감탄사·이모지 금지.",
"주어진 제목에 없는 사실·수치를 지어내지 마라.",
' 출력은 JSON 한 줄만: {"title":"...","body":"..."}',
"",
"매체: %s" % src,
"제목: %s" % title,
]
msg = client.messages.create(model="claude-sonnet-4-6", max_tokens=300,
messages=[{"role": "user", "content": "\n".join(rules)}])
raw = "".join(getattr(b, "text", "") for b in msg.content).strip()
m = re.search(r"\{.*\}", raw, re.S)
d = json.loads(m.group(0)) if m else {}
t = str(d.get("title", "")).strip()
b = str(d.get("body", "")).strip()
if t and b:
return {"title": t[:40], "body": b[:120], "url": url}
except Exception as e:
print(" [push] compose fail:", e)
return fallback
def _push_send(payload: dict) -> dict:
if not PUSH_ADMIN_KEY:
return {"error": "PUSH_ADMIN_KEY 미설정 - 발송 불가"}
try:
r = requests.post(PUSH_API_BASE + "/push",
json={"title": payload["title"], "body": payload["body"],
"url": payload["url"], "admin_key": PUSH_ADMIN_KEY},
timeout=60)
try:
return r.json()
except Exception:
return {"error": "http %d" % r.status_code}
except Exception as e:
return {"error": str(e)[:120]}
def run_push_news(dry_run: bool = False) -> dict:
"""신규 뉴스 1건 → 알림 문안 생성 → 발송. dry_run이면 문안만 반환(발송 안 함)."""
global last_push_result
item = _push_pick()
if not item:
last_push_result = "skip: 신규 뉴스 없음"
return {"status": "skip", "reason": "푸시할 신규 뉴스 없음"}
msg = _push_compose(item)
if dry_run:
return {"status": "dry_run", "source": item.get("s"), "news": item.get("t"),
"payload": msg}
res = _push_send(msg)
ok = "error" not in res
if ok:
sent = _push_load_sent()
sent.add(item["u"])
_push_save_sent(sent)
last_push_result = "%s | %s | %s" % (datetime.datetime.now(KST).strftime("%m-%d %H:%M"),
msg["title"], json.dumps(res, ensure_ascii=False)[:120])
print(" [push] " + last_push_result)
_tg_send("[비드래프트 알림 푸시]\n제목: %s\n본문: %s\n결과: %s"
% (msg["title"], msg["body"], json.dumps(res, ensure_ascii=False)[:150]))
return {"status": "ok" if ok else "error", "payload": msg, "result": res}
def push_news_loop():
"""매일 10:00 · 17:00 KST 알림 푸시"""
while True:
try:
now = datetime.datetime.now(KST)
hm = now.strftime("%H:%M")
key = now.strftime("%Y-%m-%d ") + hm
if hm in PUSH_SLOTS and key not in _push_done:
_push_done.add(key)
run_push_news()
except Exception as e:
print(" [push] loop err:", e)
time.sleep(30)
# ═══════════════════════════════════════════════════════════════════
# 5.6 서비스 모니터링 — HF 상태 4색 + 도메인 헬스 + K-AI Top10 (2026-07-13)
# ═══════════════════════════════════════════════════════════════════
MON_HF = [
("VIDRAFT 홈페이지", "VIDraft/AI"), ("VKAE 추론가속", "VIDraft/vkae"), ("PharmaOS", "VIDraft/PharmaOS"),
("CHITOS 보안", "VIDraft/chitos"), ("Darwin-398B Vision", "VIDraft/Darwin-398B-Vision"),
("QuantumOS", "VIDraft/quantumos"), ("FloorPlan AI", "VIDraft/FloorPlan-AI"),
("제타 음악", "VIDraft/zeta-zeros"), ("MARL 추론엔진", "VIDraft/MARL"), ("LLM MRI 진단", "VIDraft/MODEL-MRI"),
("TeXray 표절검사", "VIDraft/TeXray"), ("논문 생성기", "VIDraft/paper"),
("VKUE 리더보드", "FINAL-Bench/VKUE"), ("Ourbox GPU/CPU 데모", "FINAL-Bench/Ourbox-35B-VKUE-Demo"),
("Ourbox CPU 데모", "FINAL-Bench/Ourbox-35B-VKUE-CPU"), ("양자 리더보드", "FINAL-Bench/quantum-bench-leaderboard"),
("올벤치 리더보드", "FINAL-Bench/all-bench-leaderboard"), ("월드모델 데모", "FINAL-Bench/World-Model"),
("보안 스캔", "FINAL-Bench/security-scan"), ("Darwin TTS", "FINAL-Bench/Darwin-TTS-1.7B-Cross"),
("모델 갤럭시", "FINAL-Bench/model-galaxy"), ("메타인지 리더보드", "FINAL-Bench/Leaderboard"),
("Darwin 리스트", "Heartsync/darwin_list"),
]
MON_URL = [
("JGOS Citizen AI", "https://jgos.vidraft.net/"), ("ADMET 예측", "https://admet.1street.ai/"),
("CHITOS 웹", "https://chitos.vidraft.net/"), ("vidraft.net", "https://vidraft.net/"),
("Darwin Avatar", "https://avatar.vidraft.net/"),
]
mon_status = {"updated": "", "services": [], "leaderboard": []}
def _hf_stage_color(stage):
s = (stage or "").upper()
if s == "RUNNING": return ("green", "정상")
if s == "PAUSED": return ("black", "수동중지")
if s in ("SLEEPING", "STOPPED"): return ("blue", "자동중지")
if "ERROR" in s: return ("red", "오류")
if "BUILD" in s or "STARTING" in s: return ("amber", "기동중")
return ("gray", s or "확인불가")
def _mon_check():
import urllib.request as U
hdr = {"User-Agent": "Mozilla/5.0"}
if HF_TOKEN:
hdr["Authorization"] = "Bearer " + HF_TOKEN
svcs = []
for name, sid in MON_HF:
url = "https://huggingface.co/spaces/" + sid
try:
d = json.loads(U.urlopen(U.Request("https://huggingface.co/api/spaces/" + sid, headers=hdr), timeout=15).read().decode("utf-8", "ignore"))
stage = (d.get("runtime") or {}).get("stage", "")
color, label = _hf_stage_color(stage)
except Exception:
color, label, stage = "gray", "조회실패", ""
svcs.append({"name": name, "kind": "hf", "url": url, "color": color, "label": label, "stage": stage})
for name, u in MON_URL:
try:
r = requests.get(u, timeout=12, allow_redirects=True, headers={"User-Agent": "Mozilla/5.0"})
color, label = ("green", "정상") if r.status_code < 400 else ("red", "다운 %d" % r.status_code)
except Exception:
color, label = "red", "응답없음"
svcs.append({"name": name, "kind": "url", "url": u, "color": color, "label": label, "stage": ""})
lb = []
try:
h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "Referer": "https://leaderboard.aihub.or.kr/leaderboard"}
d = json.loads(U.urlopen(U.Request("https://leaderboard.aihub.or.kr/proxy/api/leaderboard?page=0&size=10&sortType=TOTAL&direction=DESC", headers=h), timeout=15).read().decode("utf-8", "ignore"))
for r in d.get("rows", [])[:10]:
nm = r.get("mdlNm", "") or ""; inst = r.get("instNm", "") or ""
ours = any(k in (nm + inst) for k in ["지니젠", "VIDRAFT", "VIDraft", "vidraft", "FINAL", "Darwin", "Rogue", "JGOS", "AWAXIS"])
lb.append({"rank": r.get("rank"), "model": nm, "inst": inst, "score": r.get("totalScore"), "url": r.get("modelUrl", ""), "ours": ours})
except Exception as e:
print(" [mon] leaderboard fail:", e)
mon_status["services"] = svcs
mon_status["leaderboard"] = lb
mon_status["updated"] = datetime.datetime.now(KST).strftime("%Y-%m-%d %H:%M KST")
def mon_loop():
time.sleep(20)
while True:
try:
_mon_check()
except Exception as e:
print(" [mon] loop err:", e)
time.sleep(240)
# ═══════════════════════════════════════════════════════════════════
# 6. FastAPI 엔드포인트
# ═══════════════════════════════════════════════════════════════════
@app.on_event("startup")
async def startup():
global articles_cache, next_gen_time
print("🚀 브런치 다이어리 시작...")
articles_cache = load_index()
print(f" 기존 글 {len(articles_cache)}개 로드")
next_gen_time = _next_8am_kst().strftime("%Y-%m-%d %H:%M KST")
threading.Thread(target=scheduler_loop, daemon=True).start()
threading.Thread(target=stats_loop, daemon=True).start()
threading.Thread(target=threads_loop, daemon=True).start()
threading.Thread(target=mon_loop, daemon=True).start()
threading.Thread(target=push_news_loop, daemon=True).start()
print(" 스케줄러 시작 (Medium 12h / 통계 6h / Threads 자동게시+08시보고 / 서비스 모니터 4분)")
def _article_to_dict(a: dict) -> dict:
"""공통 직렬화 — created_at / article_text 포함 (Blog 카드 JS 호환)"""
# created_at: 기존 항목에 없으면 ts 문자열에서 파싱
created_at = a.get("created_at")
if not created_at:
try:
created_at = int(datetime.datetime.strptime(a["ts"], "%Y-%m-%d_%H%M").timestamp())
except Exception:
created_at = 0
full_text = a.get("full", a.get("article_text", ""))
return {
"id": a["id"],
"title": a["title"],
"ts": a["ts"],
"created_at": created_at,
"preview": a.get("preview", "")[:200],
"article_text": full_text, # Blog 카드 JS가 사용하는 필드
"full": full_text,
"images": a.get("images", []),
"seo": a.get("seo", {}),
"tags_en": a.get("seo", {}).get("tags_en", []),
"platforms": a.get("platforms", {}), # status/url/code/reason 포함
"stats": a.get("stats", {}),
}
@app.get("/api/articles")
def api_articles():
now = time.time()
medium_info = {}
if _medium_rate_limit_until > now:
remaining_h = (_medium_rate_limit_until - now) / 3600
reset_kst = datetime.datetime.fromtimestamp(
_medium_rate_limit_until,
tz=datetime.timezone(datetime.timedelta(hours=9))
).strftime("%m-%d %H:%M KST")
medium_info = {"rate_limited": True, "reset_at": int(_medium_rate_limit_until),
"remaining_hours": round(remaining_h, 1), "reset_kst": reset_kst}
else:
medium_info = {"rate_limited": False}
return JSONResponse({
"articles": [_article_to_dict(a) for a in articles_cache],
"is_generating": is_generating,
"last_generated": last_generated,
"next_gen_time": next_gen_time,
"count": len(articles_cache),
"medium_status": medium_info,
})
@app.get("/api/generate")
def api_manual_generate():
"""수동 즉시 생성 (테스트용)"""
if is_generating:
return JSONResponse({"status": "이미 생성 중입니다"})
t = threading.Thread(target=run_generation, daemon=True)
t.start()
return JSONResponse({"status": "생성 시작됨"})
@app.get("/api/debug")
def api_debug():
"""배포 진단 — Secret 로드 여부 + 마지막 Dev.to 결과 (값 노출 없음)"""
return JSONResponse({
"devto_key_set": bool(DEVTO_API_KEY),
"medium_token_set": bool(MEDIUM_TOKEN),
"manual_key_set": bool(MANUAL_PUBLISH_KEY),
"anthropic_set": bool(ANTHROPIC_API_KEY),
"hf_token_set": bool(HF_TOKEN),
"blogger_blog_id_set": bool(BLOGGER_BLOG_ID),
"blogger_token_set": bool(BLOGGER_ACCESS_TOKEN),
"last_devto_result": last_devto_result,
"last_generated": last_generated,
"next_gen_time": next_gen_time,
})
@app.post("/api/threads_search")
async def api_threads_search(request: Request):
"""Threads keyword_search - public post keyword search (needs threads_keyword_search perm). Guarded by manual key."""
try:
body = await request.json()
except Exception as e:
return JSONResponse({"status": "error", "reason": "bad json: %s" % e}, status_code=400)
if not _manual_auth_ok(body):
return JSONResponse({"status": "forbidden"}, status_code=403)
q = str(body.get("q", "")).strip()
if not q:
return JSONResponse({"status": "error", "reason": "empty q"}, status_code=400)
search_type = str(body.get("search_type", "RECENT")).upper()
if search_type not in ("TOP", "RECENT"):
search_type = "RECENT"
fields = body.get("fields") or "id,text,timestamp,permalink,username,media_type,is_quote_post"
try:
rr = requests.get("https://graph.threads.net/v1.0/keyword_search", params={
"q": q, "search_type": search_type, "fields": fields,
"access_token": THREADS_ACCESS_TOKEN,
}, timeout=30)
try:
data = rr.json()
except Exception:
data = {"raw": rr.text[:800]}
return JSONResponse({"status": ("ok" if rr.status_code == 200 else "http_%d" % rr.status_code),
"query": q, "search_type": search_type, "result": data}, status_code=200)
except Exception as e:
return JSONResponse({"status": "error", "reason": str(e)}, status_code=200)
@app.post("/api/manual_publish")
async def api_manual_publish(request: Request):
"""수동 게재 — 지정 커스텀 글을 Medium/Dev.to에 즉시 게시. DEVTO_API_KEY로 가드(공개 Space 무단호출 차단)."""
try:
body = await request.json()
except Exception as e:
return JSONResponse({"status": "error", "reason": "bad json: %s" % e}, status_code=400)
if not _manual_auth_ok(body):
return JSONResponse({"status": "forbidden"}, status_code=403)
target = body.get("target", "")
article_text = body.get("article_text", "") or ""
seo = body.get("seo", {}) or {}
if not article_text.strip():
return JSONResponse({"status": "error", "reason": "empty article_text"}, status_code=400)
if target == "medium":
return JSONResponse(publish_medium(article_text, seo))
if target == "devto":
return JSONResponse(publish_devto(article_text, seo, body.get("cover_image_url", "")))
if target == "threads":
return JSONResponse(publish_threads(article_text, seo))
return JSONResponse({"status": "error", "reason": "unknown target: %s" % target}, status_code=400)
@app.post("/api/threads_load")
async def api_threads_load(request: Request):
"""오늘의 Threads 큐를 명시적으로 적재(수동 큐레이션). DEVTO_API_KEY 가드."""
global threads_queue, _threads_curated_date
try:
body = await request.json()
except Exception:
return JSONResponse({"status": "error", "reason": "bad json"}, status_code=400)
if not _manual_auth_ok(body):
return JSONResponse({"status": "forbidden"}, status_code=403)
q = body.get("queue", [])
for it in q:
it.setdefault("status", "pending"); it.setdefault("result", None); it.setdefault("cat", "")
threads_queue = q
_threads_curated_date = datetime.datetime.now(KST).strftime("%Y-%m-%d")
_threads_save_state()
return JSONResponse({"status": "ok", "loaded": len(q)})
@app.post("/api/push_now")
async def api_push_now(request: Request):
"""알림 푸시 수동 실행. dry_run=true면 문안만 생성(발송 안 함). DEVTO_API_KEY 가드."""
try:
body = await request.json()
except Exception:
body = {}
if not _manual_auth_ok(body):
return JSONResponse({"status": "forbidden"}, status_code=403)
return JSONResponse(run_push_news(dry_run=bool(body.get("dry_run", False))))
@app.get("/api/push_status")
def api_push_status():
return JSONResponse({"slots": PUSH_SLOTS, "admin_key_set": bool(PUSH_ADMIN_KEY),
"last_result": last_push_result, "sent_count": len(_push_load_sent())})
@app.get("/api/threads_status")
def api_threads_status():
return JSONResponse({
"date": _threads_curated_date, "count": len(threads_queue),
"queue": [{"time": x.get("time"), "cat": x.get("cat"), "status": x.get("status"),
"url": (x.get("result") or {}).get("url", "")} for x in threads_queue],
"last_report": last_threads_report,
})
@app.post("/api/threads_report_now")
async def api_threads_report_now(request: Request):
try:
body = await request.json()
except Exception:
body = {}
if not _manual_auth_ok(body):
return JSONResponse({"status": "forbidden"}, status_code=403)
return JSONResponse({"report": _threads_daily_report()})
@app.get("/api/status")
def api_status():
return JSONResponse(mon_status)
@app.get("/api/stats")
def api_stats():
"""전체 노출·성과 통계 요약"""
total_views = sum(a.get("stats", {}).get("total_views", 0) for a in articles_cache)
total_reactions = sum(a.get("stats", {}).get("devto", {}).get("reactions", 0) for a in articles_cache)
total_comments = sum(a.get("stats", {}).get("devto", {}).get("comments", 0) for a in articles_cache)
articles_count = len(articles_cache)
# 플랫폼별 배포 현황
platform_counts = {}
for a in articles_cache:
for p, info in a.get("platforms", {}).items():
if info.get("status") == "ok":
platform_counts[p] = platform_counts.get(p, 0) + 1
# 인기 글 Top 5
top_articles = sorted(
[{"id": a["id"], "title": a["title"],
"views": a.get("stats", {}).get("total_views", 0),
"reactions": a.get("stats", {}).get("devto", {}).get("reactions", 0),
"platforms": {p: i.get("url","") for p, i in a.get("platforms",{}).items() if i.get("url")}}
for a in articles_cache],
key=lambda x: x["views"], reverse=True
)[:5]
return JSONResponse({
"summary": {
"articles": articles_count,
"views": total_views,
"reactions": total_reactions,
"comments": total_comments,
},
"platforms": platform_counts,
"top_articles": top_articles,
"updated_at": articles_cache[0].get("stats", {}).get("updated_at", "") if articles_cache else ""
})
@app.get("/api/stats/refresh")
def api_stats_refresh():
"""즉시 통계 갱신 (수동)"""
t = threading.Thread(target=collect_all_stats, daemon=True)
t.start()
return JSONResponse({"status": "통계 갱신 시작됨"})
@app.get("/api/urls")
def api_urls():
"""게재된 아티클별 플랫폼 URL 목록 — 최신순"""
rows = []
for a in articles_cache:
platforms = a.get("platforms", {})
urls = {
p: info.get("url", "")
for p, info in platforms.items()
if info.get("url") and info.get("status") == "ok"
}
if not urls:
continue
created_at = a.get("created_at")
if not created_at:
try:
created_at = int(datetime.datetime.strptime(a["ts"], "%Y-%m-%d_%H%M").timestamp())
except Exception:
created_at = 0
rows.append({
"id": a["id"],
"title": a.get("title", ""),
"created_at": created_at,
"date": a["ts"].replace("_", " "),
"urls": urls,
})
return JSONResponse({
"count": len(rows),
"articles": rows,
"note": "배포 성공(status=ok)된 글만 표시. 1시간 간격 자동 생성."
})
@app.get("/proxy/image/{article_id}/{idx}")
def proxy_image(article_id: str, idx: int):
"""HF private dataset 이미지 → 프록시 제공 (cache=False로 항상 최신)"""
repo_path = f"articles/{article_id}/image_{idx}.png"
try:
local = hf_hub_download(
DATASET_REPO, repo_path, repo_type="dataset", token=HF_TOKEN,
force_download=False, # 이미 로컬에 있으면 재사용
local_files_only=False # HF에서 새 파일 체크
)
return FileResponse(local, media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"})
except Exception as e:
# 파일 없음 → 404 (이미지 생성 전 or presave 실패)
return Response(status_code=404, content=b"")
@app.get("/proxy/image/{article_id}/{idx}/check")
def proxy_image_check(article_id: str, idx: int):
"""이미지 proxy 가용 여부 확인용"""
from huggingface_hub.utils import EntryNotFoundError
repo_path = f"articles/{article_id}/image_{idx}.png"
try:
hf_hub_download(DATASET_REPO, repo_path, repo_type="dataset", token=HF_TOKEN)
return JSONResponse({"exists": True, "path": repo_path})
except Exception:
return JSONResponse({"exists": False, "path": repo_path})
# ═══════════════════════════════════════════════════════════════════
# 7. SEO/AEO 블로그 — 글별 서버렌더 페이지 + sitemap + robots (자동)
# articles_cache/index.json에서 동적 렌더 → 새 글마다 자동 색인 대상
# ═══════════════════════════════════════════════════════════════════
import html as _htmlesc
def _md_inline(s: str) -> str:
import re
s = _htmlesc.escape(s, quote=False)
s = re.sub(r'!\[([^\]]*)\]\(([^)\s]+)\)', r'<img alt="\1" src="\2" loading="lazy" style="max-width:100%;border-radius:10px">', s)
s = re.sub(r'\[([^\]]+)\]\(([^)\s]+)\)', r'<a href="\2" rel="noopener" target="_blank">\1</a>', s)
s = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', s)
s = re.sub(r'(?<!\*)\*([^*\n]+)\*(?!\*)', r'<em>\1</em>', s)
s = re.sub(r'`([^`]+)`', r'<code>\1</code>', s)
return s
def _md_to_html(md: str) -> str:
import re
out = []; in_ul = [False]
def _close():
if in_ul[0]: out.append('</ul>'); in_ul[0] = False
for raw in (md or "").split('\n'):
st = raw.strip()
if not st:
_close(); continue
m = re.match(r'^(#{1,6})\s+(.*)$', st)
if m:
_close(); lvl = len(m.group(1)); out.append('<h%d>%s</h%d>' % (lvl, _md_inline(m.group(2)), lvl)); continue
if re.match(r'^[-*_]{3,}$', st):
_close(); out.append('<hr>'); continue
if st.startswith('> '):
_close(); out.append('<blockquote>%s</blockquote>' % _md_inline(st[2:])); continue
if re.match(r'^[-*]\s+', st):
if not in_ul[0]: out.append('<ul>'); in_ul[0] = True
out.append('<li>%s</li>' % _md_inline(re.sub(r'^[-*]\s+', '', st))); continue
_close(); out.append('<p>%s</p>' % _md_inline(st))
_close()
return '\n'.join(out)
_BLOG_CSS = ("body{max-width:760px;margin:0 auto;padding:34px 20px 60px;"
"font-family:-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;line-height:1.75;color:#1a1a1a}"
"h1{font-size:1.95rem;line-height:1.25;letter-spacing:-.3px}h2{margin-top:1.8em;font-size:1.35rem}"
"h3{margin-top:1.4em;font-size:1.1rem}blockquote{border-left:4px solid #6366F1;background:#f5f6ff;"
"margin:1.2em 0;padding:12px 18px;border-radius:0 8px 8px 0;color:#374151}"
"code{background:#f0f0f3;padding:2px 6px;border-radius:4px;font-size:.9em}"
"a{color:#4f46e5}img{max-width:100%}hr{border:none;border-top:1px solid #e5e7eb;margin:2em 0}"
"li{margin:.3em 0}.meta{color:#6b7280;font-size:.85rem;margin-bottom:1.4em}"
".foot{margin-top:3em;padding-top:1.4em;border-top:1px solid #e5e7eb;color:#6b7280;font-size:.85rem}")
def _blog_page_html(a: dict) -> str:
aid = a.get("id", "")
title = (a.get("title", "") or "VIDRAFT Blog").strip()
body_md = a.get("full", a.get("article_text", "")) or ""
seo = a.get("seo", {}) or {}
desc = " ".join(str(seo.get("meta_description") or a.get("preview", "") or title).split())[:160]
url = BLOG_BASE_URL + "/blog/" + aid
try:
pub = datetime.datetime.strptime(a.get("ts", ""), "%Y-%m-%d_%H%M").strftime("%Y-%m-%d")
except Exception:
pub = ""
tags = seo.get("tags_en", []) or []
jsonld = json.dumps({
"@context": "https://schema.org", "@type": "Article",
"headline": title[:110], "description": desc,
"datePublished": pub, "dateModified": pub,
"author": {"@type": "Organization", "name": "VIDRAFT", "url": "https://vidraft.net"},
"publisher": {"@type": "Organization", "name": "VIDRAFT", "url": "https://vidraft.net"},
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
"inLanguage": "en", "keywords": ", ".join(str(t) for t in tags),
}, ensure_ascii=False)
et = _htmlesc.escape(title); ed = _htmlesc.escape(desc)
return (
'<!doctype html><html lang="en"><head>'
'<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>' + et + ' · VIDRAFT</title>'
'<meta name="description" content="' + ed + '">'
'<link rel="canonical" href="' + url + '">'
'<meta name="robots" content="index,follow">'
'<meta property="og:type" content="article"><meta property="og:site_name" content="VIDRAFT">'
'<meta property="og:title" content="' + et + '"><meta property="og:description" content="' + ed + '">'
'<meta property="og:url" content="' + url + '">'
'<meta name="twitter:card" content="summary_large_image">'
'<meta name="twitter:title" content="' + et + '"><meta name="twitter:description" content="' + ed + '">'
'<script type="application/ld+json">' + jsonld + '</script>'
'<style>' + _BLOG_CSS + '</style></head><body>'
'<div class="meta">VIDRAFT · Korean Pre-AGI AI startup · ' + pub + '</div>'
+ _md_to_html(body_md) +
'<div class="foot">Published by <a href="https://vidraft.net">VIDRAFT</a> · '
'<a href="' + BLOG_BASE_URL + '/">All posts</a></div>'
'</body></html>'
)
def _find_article(article_id: str):
for x in (articles_cache or []):
if x.get("id") == article_id:
return x
for x in load_index():
if x.get("id") == article_id:
return x
return None
@app.get("/blog/{article_id}", response_class=HTMLResponse)
def blog_article(article_id: str):
a = _find_article(article_id)
if not a:
return HTMLResponse('<!doctype html><meta charset="utf-8"><title>Not found · VIDRAFT</title>'
'<meta name="robots" content="noindex"><h1>404 — post not found</h1>', status_code=404)
return HTMLResponse(_blog_page_html(a))
@app.get("/sitemap.xml")
def sitemap_xml():
items = articles_cache or load_index()
parts = ['<url><loc>' + BLOG_BASE_URL + '/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>']
for a in items:
aid = a.get("id", "")
if not aid:
continue
try:
lm = datetime.datetime.strptime(a.get("ts", ""), "%Y-%m-%d_%H%M").strftime("%Y-%m-%d")
lastmod = '<lastmod>' + lm + '</lastmod>'
except Exception:
lastmod = ''
parts.append('<url><loc>' + BLOG_BASE_URL + '/blog/' + aid + '</loc>' + lastmod +
'<changefreq>weekly</changefreq><priority>0.8</priority></url>')
xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + ''.join(parts) + '</urlset>')
return Response(content=xml, media_type="application/xml")
@app.get("/robots.txt")
def robots_txt():
return Response(content="User-agent: *\nAllow: /\nSitemap: " + BLOG_BASE_URL + "/sitemap.xml\n",
media_type="text/plain")
@app.get("/", response_class=HTMLResponse)
def root():
with open("static/index.html", encoding="utf-8") as f:
return f.read()
app.mount("/static", StaticFiles(directory="static"), name="static")