""" 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"" # ── 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'
\1', html, flags=re.MULTILINE) html = re.sub(r'\n\n', '
', html) html = f"
{html}
" 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: #\1', s)
return s
def _md_to_html(md: str) -> str:
import re
out = []; in_ul = [False]
def _close():
if in_ul[0]: out.append(''); 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('%s' % _md_inline(st[2:])); continue if re.match(r'^[-*]\s+', st): if not in_ul[0]: out.append('
%s
' % _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 ( '' '' '