Spaces:
Running
Running
| """ | |
| 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'<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 엔드포인트 | |
| # ═══════════════════════════════════════════════════════════════════ | |
| 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", {}), | |
| } | |
| 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, | |
| }) | |
| def api_manual_generate(): | |
| """수동 즉시 생성 (테스트용)""" | |
| if is_generating: | |
| return JSONResponse({"status": "이미 생성 중입니다"}) | |
| t = threading.Thread(target=run_generation, daemon=True) | |
| t.start() | |
| return JSONResponse({"status": "생성 시작됨"}) | |
| 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, | |
| }) | |
| 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) | |
| 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) | |
| 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)}) | |
| 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)))) | |
| 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())}) | |
| 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, | |
| }) | |
| 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()}) | |
| def api_status(): | |
| return JSONResponse(mon_status) | |
| 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 "" | |
| }) | |
| def api_stats_refresh(): | |
| """즉시 통계 갱신 (수동)""" | |
| t = threading.Thread(target=collect_all_stats, daemon=True) | |
| t.start() | |
| return JSONResponse({"status": "통계 갱신 시작됨"}) | |
| 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시간 간격 자동 생성." | |
| }) | |
| 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"") | |
| 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 | |
| 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)) | |
| 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") | |
| def robots_txt(): | |
| return Response(content="User-agent: *\nAllow: /\nSitemap: " + BLOG_BASE_URL + "/sitemap.xml\n", | |
| media_type="text/plain") | |
| def root(): | |
| with open("static/index.html", encoding="utf-8") as f: | |
| return f.read() | |
| app.mount("/static", StaticFiles(directory="static"), name="static") | |