57 Commits

Author SHA1 Message Date
JOUNGWOOK KWON
29cdeb2adf fix: Android 코덱 호환성 + 스톡영상 스크린녹화 필터링 + Gitea URL 업데이트
- video_assembler: yuv420p, profile high, level 4.0, movflags faststart 추가
- stock_fetcher: AI/UI 키워드 실사영상으로 변환, 스크린녹화 태그 차단
- CLAUDE.md: Gitea URL https://gitea.gru.farm/ 으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 21:24:47 +09:00
JOUNGWOOK KWON
726c593e85 feat: Reddit 수집, 쇼츠 텔레그램 미리보기, 코너 9개 체계 정비
- Reddit 트렌딩 수집기 추가 (/reddit collect, /pick 명령어)
- 쇼츠 영상 텔레그램 미리보기 후 승인 기반 YouTube 업로드
- 코너 9개로 통합 (앱추천→제품리뷰, 재테크절약→재테크, TV로보는세상/건강정보 추가)
- RSS 피드 73개로 확대 (9개 코너 전체 커버)
- 블로그 중복 검토 알림 수정, 글 잘림 방지 (max_tokens 8192)
- 제품리뷰 다중 이미지 지원, 저품질 이미지 필터링 강화
- HookOptimizer LLM 연동, 인스타/X/틱톡 스케줄러 비활성화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:56:20 +09:00
JOUNGWOOK KWON
93b2d3a264 feat: 쇼츠 품질 모듈 4종 파이프라인 연결
- MotionEngine: stock_fetcher에서 kenburns 대신 7패턴 모션 적용
- HookOptimizer: 스크립트 추출 후 훅 점수 평가 및 최적화
- CaptionTemplates: 코너별 자막 템플릿 매핑 (AI인사이트→brand_4thpath 등)
- ResilientAssembler: 클립별 개별 인코딩 + GPU 자동 감지
- video_assembler work_dir mkdir 누락 버그 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 10:02:14 +09:00
JOUNGWOOK KWON
fb5e6ddbdf feat: YouTube Shorts 파이프라인 완성 및 HJW TV 업로드 연동
- youtube_uploader.py: YOUTUBE_REFRESH_TOKEN/CLIENT_ID/CLIENT_SECRET 환경변수 폴백 추가
  (token.json 없는 Docker 환경에서 브랜드 계정 인증 가능)
- shorts_config.json: corners_eligible를 실제 블로그 코너명으로 수정
- caption_renderer.py: render_captions() 반환값 누락 수정
- get_token.py: web→installed 타입 변환, port 8080 고정, prompt=consent 추가
- get_youtube_token.py: YouTube 전용 OAuth 토큰 발급 스크립트 (별도 클라이언트)
- CLAUDE.md: 프로젝트 개요, 배포 방법, 핵심 파일, YouTube 채널 정보 추가
- publisher_bot.py: 이미지 분산 배치, SEO 검증, 버그 수정
- scheduler.py: 알림 강화, atomic write, 중복 방지, hot reload 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 09:27:48 +09:00
JOUNGWOOK KWON
15dfc39f0f feat: 텔레그램 이미지 첨부 기능 및 이미지 처리 개선
- /idea, /topic 명령어에 최대 3장 이미지 첨부 기능 추가
- 1장: 본문 최상단 배치, 2~3장: 본문 중간 균등 분산 배치
- base64 data URI 임베딩으로 핫링크 차단 문제 해결
- Claude API timeout=120s, max_retries=0 설정 (401 무한대기 방지)
- DuckDuckGo 제목 검색 폴백 및 문화/엔터 섹션 이미지 필터링

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:28:19 +09:00
JOUNGWOOK KWON
08e5bfc915 fix: og:image 도메인 검증 추가 및 외부 검색 이미지 제거
- og:image가 본문 이미지와 같은 도메인인지 검증하여 사이트 기본 이미지(KBS 브레드이발소 등) 차단
- DuckDuckGo 외부 검색 이미지 수집 제거, 참조 기사 소스 URL에서만 이미지 추출
- _search_real_article_image() 함수 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:11:24 +09:00
JOUNGWOOK KWON
6ad912a053 fix: 글 주제와 무관한 이미지(애니/게임/엔터) 필터링 추가 2026-04-01 09:17:34 +09:00
JOUNGWOOK KWON
f97d29f6be docs: AI 연동 설정 문서 추가 (docs/ai-integration.md) 2026-03-31 21:36:30 +09:00
JOUNGWOOK KWON
eaff01658c fix: 텔레그램 대화 Claude 클라이언트에 cliproxy base_url 적용 2026-03-31 21:17:55 +09:00
JOUNGWOOK KWON
a7164a7c19 fix: cliproxy 내부 URL로 변경 (192.168.0.17:8317) 2026-03-31 21:15:28 +09:00
JOUNGWOOK KWON
3d84fc6c75 fix: api_key_env ANTHROPIC_API_KEY로 복원 (cliproxy 호환) 2026-03-31 21:07:53 +09:00
JOUNGWOOK KWON
23aee1f971 feat: writer 모델 claude-opus-4-6으로 변경 (cliproxy) 2026-03-31 21:04:05 +09:00
JOUNGWOOK KWON
9ddf07eca3 fix: cliproxy base_url 경로 수정 + Sonnet 모델 설정
- base_url: https://cliproxy.gru.farm/api/provider/claude
- model: claude-sonnet-4-6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 21:03:44 +09:00
JOUNGWOOK KWON
139d621fd8 feat: Claude Opus via cliproxy 프록시 설정 + base_url 지원
- ClaudeWriter에 base_url 파라미터 추가 (프록시 지원)
- engine.json: provider=claude, cliproxy.gru.farm 프록시 설정
- fallback_chain: claude → gemini → groq 순서

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:09:14 +09:00
JOUNGWOOK KWON
1bdd212639 fix: 발행 완료된 글감을 /collect, /write 목록에서 제외
- publish() 성공 시 topics/ 에서 해당 topic 파일 자동 삭제
- /write, /collect 목록에 발행 제목 유사도 80% 필터 추가
- _load_published_titles(), _filter_unpublished() 헬퍼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 08:55:27 +09:00
JOUNGWOOK KWON
e1fb6c954a fix: 목차를 이미지 뒤에 배치 + TOC 링크 없으면 숨김
- 목차가 대표이미지 위에 나오던 문제 수정 (이미지 → 목차 → 본문 순서)
- TOC에 실제 <a> 링크가 없으면 "목차" 제목만 나오는 현상 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:59:26 +09:00
JOUNGWOOK KWON
8eb6b7a7f9 feat: Groq fallback writer 추가 — Gemini rate limit 시 자동 전환
- GroqWriter 클래스 추가 (llama-3.3-70b-versatile)
- FallbackWriter 래퍼: primary 실패/빈응답 → fallback chain 자동 시도
- engine.json에 groq 설정 + fallback_chain: ["groq"] 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:40:01 +09:00
JOUNGWOOK KWON
8a15148b7b fix: 한국어 조사 제거로 Google News RSS 검색 정확도 개선
_extract_search_keywords()에서 한국어 조사/어미(을, 에서, 에서만 등)를
regex로 제거하고 키워드를 3개로 축소하여 검색 적중률 향상.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:32:43 +09:00
JOUNGWOOK KWON
b3ccbba491 fix: Google News og:title 덮어쓰기 방지 - 원본 RSS 제목 보존
_fetch_sources_content()에서 Google News 페이지의 og:title='Google News'로
원래 RSS 기사 제목이 덮어씌워지던 문제 수정.
- 'Google News' 등 플랫폼 이름이면 무시, 원래 RSS 제목 유지
- og:description도 'google news' 포함 시 무시
이로써 DuckDuckGo 원문 기사 이미지 검색이 제대로 작동

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:28:13 +09:00
JOUNGWOOK KWON
178caade3f feat: 원문 기사 이미지 DuckDuckGo 검색 + Blogger img 삽입 방식 개선
1. _search_real_article_image(): DuckDuckGo HTML 검색으로 원문 기사 URL 찾기
   - Google News 소스 제목 → DDG 검색 → 실제 URL → og:image
   - DDG redirect URL(uddg 파라미터)에서 실제 URL 추출
2. build_full_html(): 이미지를 div 래핑 없이 body_html 맨 앞에 직접 삽입
   - Blogger가 div class를 제거하는 문제 해결
3. fetch_featured_image() 우선순위 변경:
   RSS이미지 → og:image → DDG검색(원문) → Pexels → Wikipedia

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:19:13 +09:00
JOUNGWOOK KWON
d9f932b333 fix: Wikipedia 이미지 태그 전체 시도, TOC h2>=3 스마트 복원
- fetch_featured_image: 태그 전체(최대 8개) 시도, 제목 제외(너무 길어 매칭 안됨)
  px 크기 regex로 일괄 800px 교체
- TOC: h2>=3 조건부 표시 복원 (완전제거→스마트 표시)
  두 파일(publisher_bot, blog_converter) 동일하게 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:04:21 +09:00
JOUNGWOOK KWON
b98d694b65 fix: 목차 완전 제거, Wikipedia 이미지 fallback 추가
1. publisher_bot.py + blog_converter.py: 목차(TOC) 완전 비활성화
2. fetch_featured_image(): Wikipedia REST API로 무료 이미지 fallback
   - 제목/태그로 한국어 Wikipedia 검색 → 썸네일 추출
   - 실패 시 영문 Wikipedia 시도 (최대 4개 키워드)
   - 200px 썸네일 → 800px 고해상도로 교체

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:54:07 +09:00
JOUNGWOOK KWON
f3526bbcdd fix: source_image에도 플랫폼 로고 필터 적용 (근본 원인 수정)
NAS IP에서 Google News RSS URL이 200 응답하며 og:image에 lh3.googleusercontent.com
썸네일을 반환하는 문제. 두 곳 모두 차단:
- fetch_featured_image(): source_image에 _is_platform_logo() 체크 추가
- _fetch_sources_content(): og:image 저장 전 플랫폼 로고 패턴 필터 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:46:45 +09:00
JOUNGWOOK KWON
ee91d83d37 fix: lh3.googleusercontent.com 필터 추가, Google News 리다이렉트 head→get 수정
- _is_platform_logo(): lh3.googleusercontent.com (Google News CDN 썸네일) 스킵 패턴 추가
- _fetch_og_image(): requests.head() → requests.get() (head는 리다이렉트 미작동)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:33:24 +09:00
JOUNGWOOK KWON
07703a0f6b fix: Google 뉴스 URL 리다이렉트 추적 + sources 최종 반영
- requests.head() → requests.get()으로 변경 (Google 뉴스 리다이렉트 정상 추적)
- enriched sources(실제 기사 URL)를 article에 덮어써서 출처 박스에 반영
- source_image도 크롤링된 og:image로 채워짐

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:28:00 +09:00
JOUNGWOOK KWON
3119823128 fix: /idea 긴 문장 검색 시 핵심 키워드 추출
'월드컵을 지상파에서 못보는 상황에 대한 반대의 글' → '월드컵 지상파에서 못보는'
불용어 제거 후 핵심 키워드 3~5개만 추출하여 Google 뉴스 검색.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 17:18:05 +09:00
JOUNGWOOK KWON
45a352c343 fix: feedparser URL 직접 호출 → requests + feedparser 텍스트 파싱
feedparser.parse(URL)이 NAS에서 15초+ 소요되어 타임아웃 발생.
requests.get()으로 1초에 가져온 후 feedparser.parse(text)로 파싱하면 총 1.3초.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 17:16:02 +09:00
JOUNGWOOK KWON
0e72e6de88 feat: pending 글 카테고리 변경 기능 추가
- /setcorner <번호> <카테고리>: 커맨드로 변경
- /pending 버튼에 🏷 카테고리 변경 버튼 추가 → 클릭 시 9개 카테고리 선택 버튼 표시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:13:40 +09:00
JOUNGWOOK KWON
8c66d9b9e0 debug: _fetch_sources_content 로그 추가 2026-03-30 16:24:03 +09:00
JOUNGWOOK KWON
e077b593c9 feat: /idea 글 작성 시 소스 크롤링 → Gemini에 실제 내용 전달
/write 시점에 Google 뉴스 URL → 실제 기사 URL 변환 후 내용 크롤링.
Gemini 프롬프트에 기사 제목+요약+URL 전달 → 실제 소스 기반 글 작성.
출처 박스에 실제 기사 링크 표시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:20:26 +09:00
JOUNGWOOK KWON
6d9cf8d6da feat: /idea sources 배열을 Gemini 프롬프트와 출처 섹션에 반영
- sources 배열의 제목+URL을 참고 자료로 프롬프트에 포함
- SOURCES 섹션에 각 기사 URL·제목·날짜 모두 기재
- 소스 없을 때는 "AI 자체 지식 활용" 명시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:17:51 +09:00
JOUNGWOOK KWON
7fd57e61b4 fix: /idea RSS 소스 검색 복원 + 15초 타임아웃 폴백
검색 성공 시 소스 표시, 15초 초과 시 키워드만으로 저장 후 진행.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 15:45:54 +09:00
JOUNGWOOK KWON
b41e8d0ff2 fix: /idea 타임아웃 — 리다이렉트/크롤링 제거하고 RSS만 파싱
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 15:39:57 +09:00
JOUNGWOOK KWON
f25a95440a fix: /idea 타임아웃 — 리다이렉트/크롤링 제거하고 RSS만 파싱
NAS→Google 뉴스 리다이렉트 추적이 매우 느려서 Telegram 타임아웃 발생.
RSS 피드 파싱만으로 제목/설명 수집, URL 변환은 글 작성 시점에 처리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:36:01 +09:00
JOUNGWOOK KWON
c836c720da fix: /idea 명령 타임아웃 — 첫 기사만 크롤링으로 속도 최적화
5개 기사 모두 리다이렉트 추적하면 50초+ 걸려서 Telegram 타임아웃 발생.
첫 번째 기사만 URL 변환+크롤링하고 나머지는 RSS 제목만 저장.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:26:15 +09:00
JOUNGWOOK KWON
3d6736503f feat: /idea 명령 추가 — 키워드로 글감 자동 생성
텔레그램에서 /idea <키워드> [카테고리] 로 글감 등록.
- Google 뉴스 RSS로 관련 기사 최대 5개 자동 검색
- 첫 번째 기사에서 설명/이미지 크롤링
- 검색된 기사들을 sources로 저장 → 글 작성 시 참고 자료로 활용
- 카테고리 미지정 시 키워드 기반 자동 추정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:14:37 +09:00
JOUNGWOOK KWON
52c06e4cd4 fix: 목차(TOC)를 h2가 3개 이상일 때만 표시
짧은 글에서 '목차' 텍스트만 덩그러니 나오는 문제 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:59:10 +09:00
JOUNGWOOK KWON
6e92b76077 feat: /topic 명령 추가 — URL을 글감으로 등록
텔레그램에서 /topic <URL> [카테고리] 로 기사 URL을 글감으로 등록.
- 기사 크롤링: 제목, 설명, og:image, 사이트명 자동 추출
- 카테고리 미지정 시 키워드 기반 자동 추정
- Google 뉴스 URL 자동 변환
- 등록 후 /write 번호로 바로 글 작성 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:54:51 +09:00
JOUNGWOOK KWON
2fcb2d353d fix: Google 뉴스 RSS URL을 실제 기사 URL로 변환
수집 시 news.google.com/rss/articles/CBMi... 형태의 인코딩 URL을
리다이렉트 따라가서 실제 기사 URL로 저장. 출처 링크 클릭 시 원본 기사로 이동 가능.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:46:14 +09:00
JOUNGWOOK KWON
d0cabc3f13 fix: og:image에서 플랫폼 로고(Google뉴스 등) 필터링
- _is_platform_logo(): 로고/아이콘/기본이미지 패턴 감지
- Google 뉴스 URL인 경우 실제 기사 URL로 리다이렉트 추적
- 로고 이미지 걸러지면 Pexels 폴백으로 진행

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:44:53 +09:00
JOUNGWOOK KWON
adc4d252ac feat: /write 명령에 카테고리 오버라이드 기능 추가
/write 7 AI인사이트 형태로 카테고리를 변경하여 발행 가능.
두 번째 인자가 유효한 카테고리명이면 corner 오버라이드, 아니면 기존 direction으로 처리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:17:04 +09:00
JOUNGWOOK KWON
33f0c5d2b1 feat: 앱추천 카테고리를 건강정보로 교체
- blogs.json labels: 앱추천 → 건강정보
- sources.json: 앱추천 RSS 5개 삭제, 건강정보 RSS 7개 추가
  (헬스조선, 연합뉴스 건강, 메디게이트뉴스, 하이닥, 코메디닷컴, 메디컬투데이, 건강 구글뉴스)
- x_keywords: 앱 추천 → 건강 정보, 의료 뉴스, 건강 관리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:01:44 +09:00
JOUNGWOOK KWON
e250126431 fix: 글쓰기 프롬프트에서 자기소개/인사말 제거
매 글마다 "편집자 eli입니다" 반복 → 바로 주제 진입으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:57:03 +09:00
JOUNGWOOK KWON
1c6a20e7ea feat: 블로그 글 하단에 원문 출처 링크 표시
- 본문 끝에 출처 박스 추가 (배경색 + 좌측 보더)
- sources 배열과 source_url 모두 표시
- 중복 URL 제거, 새 탭 열기(target=_blank)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:54:45 +09:00
JOUNGWOOK KWON
a3fbed40ec fix: _publish_next 중복 정의 제거 — pending 이동 안 되던 근본 원인
upstream의 drafts/ 기반 _publish_next(520줄)가 수정된 originals→pending
버전(257줄)을 덮어쓰고 있었음. Python은 마지막 정의를 사용하므로
originals/에서 pending_review/로 이동이 전혀 안 되던 것.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:41:06 +09:00
JOUNGWOOK KWON
53393a6354 fix: 대표 이미지를 원본 기사 og:image 크롤링으로 변경
- Unsplash Source API 중단으로 기존 폴백 작동 안 함
- 원본 기사 URL에서 og:image / twitter:image 크롤링 (가장 확실)
- 우선순위: RSS 이미지 → og:image 크롤링 → Pexels API
- lxml 파서 사용 (이미 Docker에 설치됨)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:35:03 +09:00
JOUNGWOOK KWON
d85671e6ac fix: pending 파일명 _pending.json 접미사 추가 + 에러 복구 강화
- _publish_next에서 파일명에 _pending 접미사 추가 (get_pending_list 매칭)
- check_safety 실패 시에도 수동 검토로 전환 (무조건 pending 이동)
- safety_keywords.json의 재테크절약 → 재테크 수정
- /write 후 인라인 버튼 callback_data도 _pending 파일명 사용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:31:00 +09:00
JOUNGWOOK KWON
9280be7e52 feat: 원본 RSS 소스 이미지를 대표 이미지로 우선 사용
- RSS 수집 시 media:thumbnail, media:content, enclosure, <img> 태그에서 이미지 추출
- source_image를 topic → article → publisher로 전달
- 발행 시 우선순위: 원본 소스 이미지 → Pexels → Unsplash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:22:48 +09:00
JOUNGWOOK KWON
02484679e2 feat: 블로그 대표 이미지 자동 삽입 (Pexels/Unsplash)
- 발행 시 본문에 <img>가 없으면 자동으로 대표 이미지 추가
- Pexels API (PEXELS_API_KEY 있을 때) → Unsplash Source (무료 폴백)
- 글 태그/코너 기반 키워드로 관련 이미지 검색
- Blogger가 첫 번째 <img>를 자동으로 thumbnail로 사용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:21:06 +09:00
JOUNGWOOK KWON
7a03fb984a feat: 텔레그램 인라인 버튼으로 승인/거부 (터치 한 번으로 발행)
- /write 완료 시 미리보기 + [승인 발행] [거부] 인라인 버튼 표시
- /pending 목록도 각 글마다 인라인 버튼 포함
- 버튼 클릭 → 즉시 발행/거부 처리, 메시지 업데이트
- 기존 /approve, /reject 명령어도 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:16:14 +09:00
JOUNGWOOK KWON
01f95dbc6b fix: /collect, /topics 전체 글감 표시 (15개 제한 제거)
텔레그램 4096자 제한 고려하여 30개씩 페이지 나눠 전송

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:11:24 +09:00
JOUNGWOOK KWON
e3c963a014 feat: 영문 RSS 글감 자동 번역+재작성 지원
- 수집 시 영문 소스 자동 감지 (한국어 비율 5% 미만)
- 영문 글감 글쓰기 프롬프트에 번역+한국맥락 재작성 지시 추가
- 한국 시장 비교, 국내 대안 서비스 언급 유도
- 제목도 한국어로 새로 작성하도록 지시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:53:01 +09:00
JOUNGWOOK KWON
af57c3500c fix: 수집 필터 완화 — 영문 RSS 살리기 + 코너 자동배정 + 클릭베이트 완화
- 영문 RSS(카테고리 지정됨)에 한국관련성 기본 10점 부여 (즉시폐기 방지)
- korean_relevance 키워드에 AI/GPT/Apple/Netflix 등 글로벌 키워드 추가
- 키워드 매칭을 case-insensitive로 변경
- RSS 카테고리를 corner로 직접 배정 (쉬운세상 대신 실제 라벨)
- 클릭베이트 필터에서 충격/대박/레전드/역대급 제거 (TV뉴스 과다 필터링)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:50:47 +09:00
JOUNGWOOK KWON
0783775cdd feat: RSS 소스 17개 추가 — TV로보는세상 6개 + 기존 카테고리 보강
- TV로보는세상: 연합뉴스연예, 한경연예, MBC, TV리포트, OSEN, 스포츠조선
- AI인사이트: Google News AI, Ars Technica
- 여행맛집: Google News 여행맛집, 마이리얼트립
- 제품리뷰: 뽐뿌, Wired
- 앱추천: 9to5Mac, Android Authority
- 재테크: 조선비즈, 이데일리
- x_keywords: TV/드라마/넷플릭스 키워드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:48:11 +09:00
JOUNGWOOK KWON
2c80ed1a52 chore: 라벨 변경 — 재테크절약→재테크, TV로보는세상 추가
blogs.json, sources.json 라벨을 블로그 메뉴와 일치시킴

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:46:23 +09:00
JOUNGWOOK KWON
9cf1f44a8b fix: /collect, /topics 결과에 글 번호 표시 추가
수집 완료 후 바로 번호 포함 목록을 보여줘서
/write [번호]로 바로 글 작성할 수 있도록 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:42:28 +09:00
JOUNGWOOK KWON
9f68133217 feat: /collect, /write 텔레그램 명령어 추가 + eli 페르소나 적용
- cmd_collect: 즉시 글감 수집
- cmd_write [번호] [방향]: 특정 글감 글 작성 + auto pending
- _publish_next(): originals → pending_review 자동 이동
- _call_openclaw: direction 파라미터 지원
- 글쓰기 시스템 프롬프트 eli 블로그 페르소나로 변경
- 기본 코너: 쉬운세상 → AI인사이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:27:02 +09:00
24 changed files with 3390 additions and 235 deletions

View File

@@ -2,20 +2,61 @@
이 파일은 Claude Code가 어느 경로에서 실행되든 자동으로 로드합니다.
## 프로젝트 개요
- 블로그 자동화 시스템 (수집 → AI 작성 → 변환 → 발행 → 배포)
- 블로그: eli-ai.blogspot.com ("AI? 그게 뭔데?")
- 운영자: eli (텔레그램으로 명령/승인)
- 코너 9개: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
## 저장소
- Git 서버: Gitea (자체 NAS 운영)
- Gitea URL: http://nas.gru.farm:3001
- Gitea URL: https://gitea.gru.farm/
- 계정: airkjw
- 저장소: blog-writer
- Remote: http://nas.gru.farm:3001/airkjw/blog-writer
- Remote: https://gitea.gru.farm/airkjw/blog-writer
- 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963
## NAS ssh 공개키
## NAS
- 아이디: airkjw
- 공개키: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICkbFPXF3CHi91UsWIrIsjG8srqceVm1wKrL3K1doM1V
- 주소: nas.gru.farm:22
- 내부 IP: 192.168.0.17
- Docker 명령: sudo /usr/local/bin/docker (NOPASSWD)
- Docker Compose: sudo /usr/local/bin/docker compose
- Docker Compose: sudo /usr/local/bin/docker compose
- 앱 경로: /volume2/homes/airkjw/blog-writer-app/
- 컨테이너: blog-scheduler
## 배포 방법
~/.ssh/config에 Gitea용 `Host nas.gru.farm`(User git, Port 2222)이 있어 SCP가 안됨.
파일 전송은 반드시 아래 방식 사용:
```bash
base64 -i <로컬파일> | ssh -i ~/.ssh/id_ed25519 -p 22 -o StrictHostKeyChecking=no airkjw@nas.gru.farm "base64 -d > /volume2/homes/airkjw/blog-writer-app/<경로>"
```
배포 후 재시작:
```bash
ssh -i ~/.ssh/id_ed25519 -p 22 -o StrictHostKeyChecking=no airkjw@nas.gru.farm "sudo /usr/local/bin/docker restart blog-scheduler"
```
## 핵심 파일
- `bots/scheduler.py` — 메인 스케줄러 + 텔레그램 봇 (2100+ LOC)
- `bots/publisher_bot.py` — Blogger 발행, 이미지 처리, HTML 생성
- `bots/engine_loader.py` — AI 엔진 추상화 (Claude/Gemini/Groq)
- `config/engine.json` — AI엔진, TTS, 비디오, 발행 설정
- `config/schedule.json` — 크론 스케줄
## 텔레그램 명령어
/status, /collect, /topics, /write, /pending, /approve, /reject,
/idea, /topic, /report, /convert, /reload, /shorts, /images,
/novel_list, /novel_gen, /novel_status, /cancelimg,
/reddit, /pick
## 주의사항
- Blogger 테마가 어두운 배경 → HTML 콘텐츠 작성 시 밝은 색상(#e0e0e0) 사용
- Google News RSS URL은 NAS 컨테이너에서 리다이렉트 실패 → DuckDuckGo 폴백 사용
- 외부 이미지 hotlink 차단 문제 → 사용자 이미지는 base64 data URI 임베딩
- ClaudeWriter: timeout=120s, max_retries=0 (401 시 즉시 fallback)
## 유튜브 관련
채널명: HJW TV
채널ID: UCHu3hkjXvbKIrYNtEgl8fRA
OAuth: YouTube 전용 클라이언트 (YOUTUBE_CLIENT_ID/YOUTUBE_CLIENT_SECRET/YOUTUBE_REFRESH_TOKEN)

View File

@@ -35,13 +35,17 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# 코너별 타입
# 코너별 타입 (공식 9개 코너)
CORNER_TYPES = {
'easy_guide': '쉬운세상',
'hidden_gems': '숨은보물',
'vibe_report': '바이브리포트',
'ai_insight': 'AI인사이트',
'travel_food': '여행맛집',
'startup': '스타트업',
'tv_world': 'TV로보는세상',
'product_review': '제품리뷰',
'life_tips': '생활꿀팁',
'health': '건강정보',
'finance': '재테크',
'fact_check': '팩트체크',
'one_cut': '한컷',
}
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
@@ -95,7 +99,7 @@ def calc_freshness_score(published_at: datetime | None, max_score: int = 20) ->
return int(max_score * ratio)
def calc_korean_relevance(text: str, rules: dict) -> int:
def calc_korean_relevance(text: str, rules: dict, rss_category: str = '') -> int:
"""한국 독자 관련성 점수"""
max_score = rules['scoring']['korean_relevance']['max']
keywords = rules['scoring']['korean_relevance']['keywords']
@@ -107,11 +111,14 @@ def calc_korean_relevance(text: str, rules: dict) -> int:
base = 15 # 한국어 텍스트면 기본 15점
elif korean_ratio >= 0.05:
base = 8
elif rss_category:
# RSS 카테고리가 지정된 영문 소스는 큐레이션된 것이므로 기본점수 부여
base = 10
else:
base = 0
# 브랜드/지역 키워드 보너스
matched = sum(1 for kw in keywords if kw in text)
matched = sum(1 for kw in keywords if kw.lower() in text.lower())
bonus = min(matched * 5, max_score - base)
return min(base + bonus, max_score)
@@ -199,20 +206,34 @@ def apply_discard_rules(item: dict, rules: dict, published_titles: list[str]) ->
def assign_corner(item: dict, topic_type: str) -> str:
"""글감에 코너 배정"""
"""글감에 코너 배정 — RSS 카테고리가 있으면 우선 사용"""
rss_cat = item.get('_rss_category', '')
if rss_cat:
return rss_cat
title = item.get('topic', '').lower()
source = item.get('source', 'rss').lower()
if topic_type == 'evergreen':
if any(kw in title for kw in ['가이드', '방법', '사용법', '입문', '튜토리얼', '기초']):
return '쉬운세상'
return '숨은보물'
elif topic_type == 'trending':
if source in ['github', 'product_hunt']:
return '숨은보물'
return '쉬운세상'
else: # personality
return '바이브리포트'
# 키워드 기반 코너 분류
if any(kw in title for kw in ['ai', '인공지능', 'llm', 'gpt', 'claude', 'gemini', '머신러닝', '딥러닝']):
return 'AI인사이트'
if any(kw in title for kw in ['스타트업', '유니콘', 'vc', '시리즈', '인수']):
return '스타트업'
if any(kw in title for kw in ['드라마', '예능', '방송', '넷플릭스', '티빙', '쿠팡플레이', '출연', '시청률']):
return 'TV로보는세상'
if any(kw in title for kw in ['리뷰', '비교', '추천', '제품', '가젯', '아이폰', '갤럭시', 'ios', 'android', '', 'app', '도구', '', 'tool', '서비스', 'saas']):
return '제품리뷰'
if any(kw in title for kw in ['건강', '의료', '병원', '질병', '운동', '다이어트', '영양', '수면']):
return '건강정보'
if any(kw in title for kw in ['절약', '재테크', '투자', '주식', '부동산', '금리', '적금', '연금']):
return '재테크'
if any(kw in title for kw in ['꿀팁', '생활', '방법', '가이드', '사용법', '입문', '튜토리얼']):
return '생활꿀팁'
if any(kw in title for kw in ['팩트체크', '가짜뉴스', '논란', '진실', '검증']):
return '팩트체크'
if source in ['github', 'product_hunt']:
return '제품리뷰'
return 'AI인사이트' # 기본 코너
def calculate_quality_score(item: dict, rules: dict) -> int:
@@ -227,7 +248,7 @@ def calculate_quality_score(item: dict, rules: dict) -> int:
except Exception:
pass
kr_score = calc_korean_relevance(text, rules)
kr_score = calc_korean_relevance(text, rules, rss_category=item.get('_rss_category', ''))
fresh_score = calc_freshness_score(pub_at)
# search_demand: pytrends 연동 후 실제값 사용 (RSS 기본값 12)
search_score = item.get('search_demand_score', 12)
@@ -366,6 +387,49 @@ def collect_product_hunt(sources_cfg: dict) -> list[dict]:
return items
def _extract_rss_image(entry) -> str:
"""RSS entry에서 대표 이미지 URL 추출"""
# 1) media:thumbnail
if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:
return entry.media_thumbnail[0].get('url', '')
# 2) media:content (type이 image인 것)
if hasattr(entry, 'media_content') and entry.media_content:
for mc in entry.media_content:
if 'image' in mc.get('type', '') or mc.get('medium') == 'image':
return mc.get('url', '')
# type 없어도 url이 이미지 확장자면
url = entry.media_content[0].get('url', '')
if any(ext in url.lower() for ext in ['.jpg', '.jpeg', '.png', '.webp']):
return url
# 3) enclosures
if hasattr(entry, 'enclosures') and entry.enclosures:
for enc in entry.enclosures:
if 'image' in enc.get('type', ''):
return enc.get('href', '') or enc.get('url', '')
# 4) summary/description 안의 <img> 태그
desc = entry.get('summary', '') or entry.get('description', '')
if '<img' in desc:
import re
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', desc)
if match:
return match.group(1)
return ''
def _resolve_google_news_url(url: str) -> str:
"""Google 뉴스 RSS 인코딩 URL을 실제 기사 URL로 변환"""
if not url or 'news.google.com' not in url:
return url
try:
resp = requests.head(url, timeout=10, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0'})
if resp.url and 'news.google.com' not in resp.url:
return resp.url
except Exception:
pass
return url
def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
"""설정된 RSS 피드 수집"""
items = []
@@ -379,16 +443,27 @@ def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
pub_at = None
if hasattr(entry, 'published_parsed') and entry.published_parsed:
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
title_text = entry.get('title', '')
desc_text = entry.get('summary', '') or entry.get('description', '')
# 한국어 문자가 거의 없으면 영문 소스로 판단
combined = title_text + desc_text
kr_chars = sum(1 for c in combined if '\uac00' <= c <= '\ud7a3')
is_english = kr_chars / max(len(combined), 1) < 0.05
# 원본 기사 대표 이미지 추출
image_url = _extract_rss_image(entry)
items.append({
'topic': entry.get('title', ''),
'description': entry.get('summary', '') or entry.get('description', ''),
'topic': title_text,
'description': desc_text,
'source': 'rss',
'source_name': feed_cfg.get('name', ''),
'source_url': entry.get('link', ''),
'source_url': _resolve_google_news_url(entry.get('link', '')),
'published_at': pub_at,
'search_demand_score': 8,
'topic_type': 'trending',
'_trust_override': trust,
'_rss_category': feed_cfg.get('category', ''),
'is_english': is_english,
'source_image': image_url,
})
except Exception as e:
logger.warning(f"RSS 수집 실패 ({url}): {e}")

View File

@@ -96,8 +96,16 @@ def build_full_html(article: dict, body_html: str, toc_html: str,
json_ld = build_json_ld(article, post_url)
disclaimer = article.get('disclaimer', '')
parts = [json_ld]
if toc_html:
parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
h2_count = body_html.lower().count('<h2')
toc_has_links = toc_html and '<a ' in toc_html and h2_count >= 3
if toc_has_links:
import re as _re
m = _re.match(r'(<img\s[^>]*/>)\s*', body_html)
if m:
body_html = m.group(0) + f'<div class="toc-wrapper">{toc_html}</div>\n' + body_html[m.end():]
else:
parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
parts.append(body_html)
if disclaimer:
parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')

View File

@@ -66,13 +66,14 @@ BaseVideoGenerator = object
# ─── Writer 구현체 ──────────────────────────────────────
class ClaudeWriter(BaseWriter):
"""Anthropic Claude API를 사용하는 글쓰기 엔진"""
"""Anthropic Claude API를 사용하는 글쓰기 엔진 (프록시 base_url 지원)"""
def __init__(self, cfg: dict):
self.api_key = os.getenv(cfg.get('api_key_env', 'ANTHROPIC_API_KEY'), '')
self.model = cfg.get('model', 'claude-opus-4-5')
self.model = cfg.get('model', 'claude-sonnet-4-6')
self.max_tokens = cfg.get('max_tokens', 4096)
self.temperature = cfg.get('temperature', 0.7)
self.base_url = cfg.get('base_url', '') # 프록시 URL (옵션)
def write(self, prompt: str, system: str = '') -> str:
if not self.api_key:
@@ -80,7 +81,14 @@ class ClaudeWriter(BaseWriter):
return ''
try:
import anthropic
client = anthropic.Anthropic(api_key=self.api_key)
client_kwargs = {
'api_key': self.api_key,
'timeout': 120.0, # 2분 타임아웃
'max_retries': 0, # 401 등 에러 시 재시도 안 함 → 즉시 fallback
}
if self.base_url:
client_kwargs['base_url'] = self.base_url
client = anthropic.Anthropic(**client_kwargs)
kwargs: dict = {
'model': self.model,
'max_tokens': self.max_tokens,
@@ -89,6 +97,8 @@ class ClaudeWriter(BaseWriter):
if system:
kwargs['system'] = system
message = client.messages.create(**kwargs)
if message.stop_reason == 'max_tokens':
logger.warning(f"ClaudeWriter: 응답이 max_tokens({self.max_tokens})에서 잘림 — 글이 불완전할 수 있음")
return message.content[0].text
except Exception as e:
logger.error(f"ClaudeWriter 오류: {e}")
@@ -194,6 +204,41 @@ class GeminiWriter(BaseWriter):
return ''
class GroqWriter(BaseWriter):
"""Groq API를 사용하는 글쓰기 엔진 (Gemini fallback용)"""
def __init__(self, cfg: dict):
self.api_key = os.getenv(cfg.get('api_key_env', 'GROQ_API_KEY'), '')
self.model = cfg.get('model', 'llama-3.3-70b-versatile')
self.max_tokens = cfg.get('max_tokens', 4096)
self.temperature = cfg.get('temperature', 0.7)
def write(self, prompt: str, system: str = '') -> str:
if not self.api_key:
logger.warning("GROQ_API_KEY 없음 — GroqWriter 비활성화")
return ''
try:
from groq import Groq
client = Groq(api_key=self.api_key)
messages = []
if system:
messages.append({'role': 'system', 'content': system})
messages.append({'role': 'user', 'content': prompt})
response = client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=self.max_tokens,
temperature=self.temperature,
)
return response.choices[0].message.content or ''
except ImportError:
logger.warning("groq 미설치 — GroqWriter 비활성화")
return ''
except Exception as e:
logger.error(f"GroqWriter 오류: {e}")
return ''
class ClaudeWebWriter(BaseWriter):
"""Playwright Chromium에 세션 쿠키를 주입해 claude.ai를 자동화하는 Writer
@@ -540,6 +585,43 @@ class ExternalGenerator(BaseImageGenerator):
return False
# ─── Fallback Writer ──────────────────────────────────────
class FallbackWriter(BaseWriter):
"""Primary writer 실패 시 fallback chain으로 자동 재시도"""
def __init__(self, primary_name: str, primary: BaseWriter,
fallbacks: list[tuple[str, BaseWriter]]):
self._primary_name = primary_name
self._primary = primary
self._fallbacks = fallbacks # [(provider_name, writer), ...]
def write(self, prompt: str, system: str = '') -> str:
# 1차: primary
try:
result = self._primary.write(prompt, system)
if result and result.strip():
return result
logger.warning(f"{self._primary_name} 빈 응답 — fallback 시도")
except Exception as e:
logger.warning(f"{self._primary_name} 실패: {e} — fallback 시도")
# 2차: fallback chain
for fb_name, fb_writer in self._fallbacks:
try:
logger.info(f"Fallback writer 시도: {fb_name}")
result = fb_writer.write(prompt, system)
if result and result.strip():
logger.info(f"Fallback 성공: {fb_name}")
return result
logger.warning(f"{fb_name} 빈 응답")
except Exception as e:
logger.warning(f"{fb_name} fallback 실패: {e}")
logger.error("모든 writer (primary + fallback) 실패")
return ''
# ─── EngineLoader ───────────────────────────────────────
class EngineLoader:
@@ -601,22 +683,43 @@ class EngineLoader:
else:
logger.warning(f"update_provider: 알 수 없는 카테고리 '{category}'")
def get_writer(self) -> BaseWriter:
"""현재 설정된 writing provider에 맞는 BaseWriter 구현체 반환"""
def get_writer(self, with_fallback: bool = True) -> BaseWriter:
"""현재 설정된 writing provider에 맞는 BaseWriter 구현체 반환.
with_fallback=True이면 FallbackWriter로 감싸서 자동 재시도."""
writing_cfg = self._config.get('writing', {})
provider = writing_cfg.get('provider', 'claude')
options = writing_cfg.get('options', {}).get(provider, {})
fallback_chain = writing_cfg.get('fallback_chain', [])
writers = {
'claude': ClaudeWriter,
'openclaw': OpenClawWriter,
'gemini': GeminiWriter,
'groq': GroqWriter,
'claude_web': ClaudeWebWriter,
'gemini_web': GeminiWebWriter,
}
cls = writers.get(provider, ClaudeWriter)
logger.info(f"Writer 로드: {provider} ({cls.__name__})")
return cls(options)
primary = cls(options)
if not with_fallback or not fallback_chain:
return primary
# fallback 체인 구성
fallback_writers = []
for fb_provider in fallback_chain:
if fb_provider == provider:
continue
fb_cls = writers.get(fb_provider)
fb_opts = writing_cfg.get('options', {}).get(fb_provider, {})
if fb_cls:
fallback_writers.append((fb_provider, fb_cls(fb_opts)))
if not fallback_writers:
return primary
return FallbackWriter(provider, primary, fallback_writers)
def get_tts(self) -> BaseTTS:
"""현재 설정된 tts provider에 맞는 BaseTTS 구현체 반환"""

View File

@@ -206,17 +206,605 @@ def build_json_ld(article: dict, blog_url: str = '') -> str:
return f'<script type="application/ld+json">\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n</script>'
def _is_platform_logo(image_url: str) -> bool:
"""플랫폼 로고/아이콘/광고 이미지인지 판별 — 대표 이미지로 부적합"""
skip_patterns = [
'logo', 'icon', 'avatar', 'banner', '/ad/',
'google.com/images/branding', 'googlenews', 'google-news',
'lh3.googleusercontent.com', # Google News CDN 썸네일
'facebook.com', 'twitter.com', 'naver.com/favicon',
'default_image', 'placeholder', 'noimage', 'no-image',
'og-default', 'share-default', 'sns_', 'common/',
# 광고/게임/이벤트 관련 패턴
'ad.', 'ads.', '/adv/', '/promo/', '/event/', '/game/',
'adimg', 'adserver', 'doubleclick', 'googlesyndication',
'akamaihd.net', 'cdn.ad', 'click.', 'tracking.',
]
url_lower = image_url.lower()
return any(p in url_lower for p in skip_patterns)
def _is_relevant_image(image_url: str, article: dict) -> bool:
"""이미지가 글 주제와 관련 있는지 판별"""
if not image_url:
return False
url_lower = image_url.lower()
# 엔터테인먼트/애니메이션/게임 관련 URL 패턴 — 글 주제와 무관할 가능성 높음
entertainment_patterns = [
'game', 'gaming', 'casino', 'slot', 'poker', 'lottery',
'anime', 'animation', 'cartoon', 'drama', 'movie', 'film',
'entertainment', 'kpop', 'idol', 'singer', 'actor',
'breadbarbershop', 'bread', 'character', 'webtoon',
'advert', 'sponsor', 'promo', 'event_banner', 'event/',
'/show/', '/program/', '/tv/', '/ott/',
]
# 글 코너/태그 추출
corner = article.get('corner', '').lower()
tags = article.get('tags', [])
if isinstance(tags, str):
tags = [t.strip().lower() for t in tags.split(',')]
else:
tags = [t.lower() for t in tags]
topic = article.get('topic', '').lower() + ' ' + article.get('title', '').lower()
# 경제/IT/사회 관련 글인데 엔터테인먼트 이미지면 거부
serious_corners = ['ai인사이트', '스타트업', '재테크', '경제', '사회', '정치', '국제']
is_serious = any(c in corner for c in serious_corners) or any(
kw in topic for kw in ['경제', '투자', '금융', '정책', '기술', 'ai', '스타트업']
)
if is_serious and any(p in url_lower for p in entertainment_patterns):
logger.info(f"이미지 관련성 불일치로 제외: {image_url[:80]}")
return False
return True
def _fetch_og_image(url: str, skip_irrelevant_section: bool = True) -> str:
"""원본 기사 URL에서 og:image 메타태그 크롤링 (본문 이미지 검증 포함)"""
if not url or not url.startswith('http'):
return ''
# 문화/엔터/스포츠 섹션 기사는 이미지 추출 건너뜀
if skip_irrelevant_section and _is_irrelevant_article_url(url):
logger.info(f"무관한 섹션 기사 이미지 건너뜀: {url[:80]}")
return ''
# Google 뉴스 리다이렉트인 경우 실제 기사 URL 추출 시도
if 'news.google.com' in url:
try:
resp = requests.get(url, timeout=15, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if resp.url and 'news.google.com' not in resp.url:
url = resp.url
logger.info(f"Google News 리다이렉트 성공: {url[:80]}")
# 리다이렉트된 실제 기사 URL도 섹션 검증
if skip_irrelevant_section and _is_irrelevant_article_url(url):
logger.info(f"리다이렉트된 기사가 무관한 섹션: {url[:80]}")
return ''
else:
logger.info(f"Google News 리다이렉트 실패 — 여전히 news.google.com")
return ''
except Exception as e:
logger.warning(f"Google News 리다이렉트 실패: {e}")
return ''
try:
resp = requests.get(url, timeout=10, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
})
if resp.status_code != 200:
logger.info(f"기사 페이지 접근 실패 (HTTP {resp.status_code}): {url[:80]}")
return ''
soup = BeautifulSoup(resp.text, 'lxml')
# 본문 내 실제 이미지 수집 (기사 내용과 관련된 이미지만 신뢰)
body_images = []
for img in soup.find_all('img', src=True):
src = img['src']
if src.startswith('http') and not _is_platform_logo(src):
body_images.append(src)
# og:image 추출
og_url = ''
og = soup.find('meta', property='og:image')
if og and og.get('content', '').startswith('http'):
if not _is_platform_logo(og['content']):
og_url = og['content']
if not og_url:
tw = soup.find('meta', attrs={'name': 'twitter:image'})
if tw and tw.get('content', '').startswith('http'):
if not _is_platform_logo(tw['content']):
og_url = tw['content']
if og_url and body_images:
from urllib.parse import urlparse
og_domain = urlparse(og_url).netloc.replace('www.', '')
body_domains = {urlparse(u).netloc.replace('www.', '') for u in body_images}
if og_domain in body_domains:
logger.info(f"og:image 도메인 일치 → 사용: {og_url[:80]}")
return og_url
# 도메인 불일치 → 사이트 기본 og:image일 가능성 → 본문 이미지 우선
logger.info(f"og:image 도메인({og_domain}) ≠ 본문 이미지 도메인 → 본문 이미지 사용")
return body_images[0]
elif og_url:
logger.info(f"og:image 사용 (본문 이미지 없음): {og_url[:80]}")
return og_url
elif body_images:
logger.info(f"본문 이미지 사용: {body_images[0][:80]}")
return body_images[0]
logger.info(f"이미지 없음: {url[:80]}")
except Exception as e:
logger.warning(f"og:image 크롤링 실패 ({url[:60]}): {e}")
return ''
def _fetch_article_images(article: dict, max_images: int = 5) -> list[str]:
"""원문 기사에서 유효한 이미지 여러 장 수집 (제품리뷰용).
source_url + sources 리스트의 URL을 크롤링하여
본문 이미지를 최대 max_images장까지 수집한다.
각 이미지는 HEAD 요청으로 접근 가능 여부를 검증한다.
"""
from urllib.parse import urlparse
urls_to_try = []
source_url = article.get('source_url', '')
if source_url and source_url.startswith('http'):
urls_to_try.append(source_url)
for src in article.get('sources', [])[:3]:
u = src.get('url', '') or src.get('link', '')
if u and u.startswith('http') and u not in urls_to_try:
urls_to_try.append(u)
collected = []
seen_urls = set()
# 프로필/아바타/저자 사진 등 제외 패턴
_skip_patterns = [
'avatar', 'author', 'profile', 'headshot', 'byline', 'gravatar',
'contributor', 'writer', 'staff', 'reporter', 'journalist',
'user-photo', 'user_photo', 'user-image', 'user_image',
'thumbnail-small', 'thumb-small', '/people/', '/person/',
'social-icon', 'share-', 'btn-', 'button', '/emoji/',
'badge', 'rating', 'star', 'pixel.', 'spacer', 'blank.',
'/1x1', 'tracking', 'analytics', 'beacon',
]
for page_url in urls_to_try:
if len(collected) >= max_images:
break
# Google News 리다이렉트 처리
if 'news.google.com' in page_url:
try:
resp = requests.get(page_url, timeout=15, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if resp.url and 'news.google.com' not in resp.url:
page_url = resp.url
else:
continue
except Exception:
continue
try:
resp = requests.get(page_url, timeout=10, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
})
if resp.status_code != 200:
continue
soup = BeautifulSoup(resp.text, 'lxml')
# 본문 영역 우선 탐색 (사이드바/푸터 이미지 제외)
article_body = (
soup.find('article') or
soup.find('div', class_=re.compile(r'article|post|entry|content|body', re.I)) or
soup.find('main') or
soup
)
for img in article_body.find_all('img', src=True):
if len(collected) >= max_images:
break
src = img['src']
if not src.startswith('http') or _is_platform_logo(src):
continue
src_lower = src.lower()
# 프로필/아바타/추적 이미지 제외
if any(p in src_lower for p in _skip_patterns):
logger.debug(f"프로필/아바타 이미지 제외: {src[:80]}")
continue
# alt/class 속성으로 프로필 사진 추가 필터링
img_alt = (img.get('alt', '') or '').lower()
img_class = ' '.join(img.get('class', []) or []).lower()
if any(p in img_alt for p in ['author', 'avatar', 'profile', 'headshot', 'byline']):
continue
if any(p in img_class for p in ['avatar', 'author', 'profile', 'byline', 'social']):
continue
# 부모 요소가 author/byline 영역이면 제외
parent = img.find_parent(['div', 'span', 'figure', 'a', 'section'])
if parent:
parent_class = ' '.join(parent.get('class', []) or []).lower()
parent_id = (parent.get('id', '') or '').lower()
if any(p in (parent_class + parent_id) for p in ['author', 'byline', 'avatar', 'profile', 'sidebar', 'related']):
continue
# 크기 힌트 — 너무 작은 이미지 제외 (300px 미만)
width = img.get('width', '')
height = img.get('height', '')
try:
if width and int(str(width).replace('px', '')) < 300:
continue
if height and int(str(height).replace('px', '')) < 150:
continue
except (ValueError, TypeError):
pass
# 중복 제거 (같은 이미지의 리사이즈 버전 등)
parsed = urlparse(src)
base_key = parsed.netloc + parsed.path.rsplit('.', 1)[0] if '.' in parsed.path else src
if base_key in seen_urls:
continue
seen_urls.add(base_key)
# HEAD 요청으로 접근 가능 + 파일 크기 확인
try:
head = requests.head(src, timeout=5, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if head.status_code != 200:
continue
ct = head.headers.get('Content-Type', '')
if ct and 'image' not in ct:
continue
# 파일 크기 10KB 미만이면 아이콘/썸네일일 가능성 높음
cl = head.headers.get('Content-Length', '')
if cl and int(cl) < 10000:
logger.debug(f"작은 이미지 제외 ({cl} bytes): {src[:80]}")
continue
collected.append(src)
logger.info(f"원문 이미지 수집 [{len(collected)}/{max_images}]: {src[:80]}")
except Exception:
continue
except Exception as e:
logger.warning(f"원문 이미지 크롤링 실패 ({page_url[:60]}): {e}")
continue
logger.info(f"원문 이미지 수집 완료: {len(collected)}장 (최대 {max_images})")
return collected
def _is_irrelevant_article_url(url: str) -> bool:
"""기사 URL 경로가 문화/엔터/스포츠 등 무관한 섹션인지 판별"""
url_lower = url.lower()
irrelevant_paths = [
'/culture/', '/entertainment/', '/sport/', '/sports/',
'/lifestyle/', '/celebrity/', '/drama/', '/movie/',
'/game/', '/gaming/', '/webtoon/', '/comic/',
'/tv/', '/ott/', '/show/', '/program/',
'/fun/', '/photo/', '/video/', '/gallery/',
]
return any(p in url_lower for p in irrelevant_paths)
def _search_article_image_by_title(sources: list) -> str:
"""Google News 소스 제목으로 DuckDuckGo 검색 → 실제 기사 URL → og:image 크롤링
Google News 리다이렉트 실패 시 폴백으로 사용"""
from urllib.parse import quote as _quote, urlparse, parse_qs, unquote
for src in sources[:3]:
title = src.get('title', '')
if not title:
continue
# "기사 제목 - 매체명" → 매체명 제거
clean_title = re.sub(r'\s*[-–—]\s*\S+$', '', title).strip()
if len(clean_title) < 5:
clean_title = title
try:
ddg_url = f'https://html.duckduckgo.com/html/?q={_quote(clean_title)}'
resp = requests.get(ddg_url, timeout=10, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
})
if resp.status_code != 200:
continue
soup = BeautifulSoup(resp.text, 'lxml')
for a_tag in soup.select('a.result__a')[:3]:
href = a_tag.get('href', '')
real_url = href
if 'uddg=' in href:
parsed = parse_qs(urlparse(href).query)
uddg = parsed.get('uddg', [''])[0]
if uddg:
real_url = unquote(uddg)
if not real_url.startswith('http'):
continue
if 'news.google.com' in real_url:
continue
# 문화/엔터/스포츠 섹션 기사는 건너뜀
if _is_irrelevant_article_url(real_url):
logger.info(f"무관한 섹션 기사 건너뜀: {real_url[:80]}")
continue
img = _fetch_og_image(real_url)
if img:
logger.info(f"제목 검색으로 이미지 발견: {clean_title[:30]}{img[:60]}")
return img
except Exception as e:
logger.debug(f"제목 검색 실패: {e}")
return ''
def fetch_featured_image(article: dict) -> str:
"""대표 이미지: RSS 이미지 → 참조 기사 og:image → Wikipedia 순으로 시도
참조된 기사 내 이미지만 사용하여 무관한 이미지 유입을 방지한다."""
logger.info(f"대표 이미지 검색 시작: {article.get('title', '')[:40]}")
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 + 관련성 검사)
source_image = article.get('source_image', '')
if source_image and source_image.startswith('http') and not _is_platform_logo(source_image):
if _is_relevant_image(source_image, article):
try:
resp = requests.head(source_image, timeout=5, allow_redirects=True)
if resp.status_code == 200:
return source_image
except Exception:
pass
# 2) 참조 기사(sources) URL에서 og:image/본문 이미지 크롤링
# source_url 및 sources 리스트의 URL만 사용 (외부 검색 X)
tried_urls = set()
source_url = article.get('source_url', '')
if source_url:
tried_urls.add(source_url)
og_image = _fetch_og_image(source_url)
if og_image and _is_relevant_image(og_image, article):
return og_image
sources = article.get('sources', [])
for src in sources[:5]:
src_url = src.get('url', '') or src.get('link', '')
if not src_url or src_url in tried_urls:
continue
tried_urls.add(src_url)
og_image = _fetch_og_image(src_url)
if og_image and _is_relevant_image(og_image, article):
return og_image
# 3) Google News 리다이렉트 실패 시 → 기사 제목으로 DuckDuckGo 검색 폴백
if sources:
logger.info("소스 URL 직접 접근 실패 → 기사 제목으로 검색 폴백")
title_image = _search_article_image_by_title(sources)
if title_image and _is_relevant_image(title_image, article):
return title_image
# 4) Wikipedia 썸네일 (무료, API 키 불필요)
tags = article.get('tags', [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
search_keywords = [t for t in tags if t and len(t) <= 15][:8]
from urllib.parse import quote as _quote
for kw in search_keywords:
for lang in ['ko', 'en']:
try:
wiki_url = f'https://{lang}.wikipedia.org/api/rest_v1/page/summary/{_quote(kw)}'
resp = requests.get(wiki_url, timeout=6,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if resp.status_code == 200:
data = resp.json()
thumb = data.get('thumbnail', {}).get('source', '')
if thumb and thumb.startswith('http') and not _is_platform_logo(thumb):
thumb = re.sub(r'/\d+px-', '/800px-', thumb)
logger.info(f"Wikipedia({lang}) 이미지 사용: {kw}{thumb[:60]}")
return thumb
except Exception:
pass
# 5) Unsplash 무료 이미지 검색 (API 키 불필요 — source 파라미터로 크레딧 자동 표시)
title = article.get('title', '')
unsplash_query = title[:50] if title else (search_keywords[0] if search_keywords else '')
if unsplash_query:
try:
unsplash_url = f'https://source.unsplash.com/800x450/?{_quote(unsplash_query)}'
resp = requests.head(unsplash_url, timeout=8, allow_redirects=True)
if resp.status_code == 200 and 'images.unsplash.com' in resp.url:
logger.info(f"Unsplash 이미지 사용: {unsplash_query[:30]}{resp.url[:60]}")
return resp.url
except Exception:
pass
return ''
def _insert_toc_after_image(body_html: str, toc_block: str) -> str:
"""본문에 대표이미지가 있으면 이미지 뒤에, 없으면 맨 앞에 TOC 삽입"""
import re as _re
# 본문 시작이 <img 태그이면 그 뒤에 삽입
m = _re.match(r'(<img\s[^>]*/>)\s*', body_html)
if m:
return m.group(0) + toc_block + body_html[m.end():]
return toc_block + body_html
def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
"""최종 HTML 조합: JSON-LD + 목차 + 본문 + 면책 문구"""
"""최종 HTML 조합: 대표이미지 + JSON-LD + 목차 + 본문 + 면책 문구"""
json_ld = build_json_ld(article)
disclaimer = article.get('disclaimer', '')
html_parts = [json_ld]
if toc_html:
html_parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
# 본문에 이미 <img> 태그가 있는지 확인 — 깨진 외부 이미지는 제거
import re as _re_img
_img_pattern = _re_img.compile(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?\s*>', _re_img.IGNORECASE)
_img_matches = list(_img_pattern.finditer(body_html))
if _img_matches:
# 외부 이미지 URL 접근 가능 여부 체크 — 깨진 이미지 제거
for m in reversed(_img_matches):
src = m.group(1)
if src.startswith('data:'):
continue # base64는 항상 유효
try:
resp = requests.head(src, timeout=5, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if resp.status_code != 200:
logger.warning(f"깨진 이미지 제거: {src[:80]} (HTTP {resp.status_code})")
body_html = body_html[:m.start()] + body_html[m.end():]
except Exception:
logger.warning(f"깨진 이미지 제거 (접속 실패): {src[:80]}")
body_html = body_html[:m.start()] + body_html[m.end():]
has_image = '<img ' in body_html.lower()
html_parts = []
if not has_image:
title = article.get('title', '').replace('"', '&quot;')
user_images = article.get('user_images', [])
# 하위호환: user_image 단일 필드도 지원
if not user_images:
single = article.get('user_image', '')
if single:
user_images = [single]
# 유효한 이미지 파일만 필터링
valid_user_images = [p for p in user_images if Path(p).exists()]
if valid_user_images:
# 사용자가 텔레그램으로 첨부한 이미지 → base64 data URI (핫링크 문제 없음)
import base64 as _b64
import re as _re
img_tags = []
for img_path in valid_user_images:
img_bytes = Path(img_path).read_bytes()
ext = Path(img_path).suffix.lower()
mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.webp': 'image/webp'}.get(ext, 'image/jpeg')
data_uri = f"data:{mime};base64,{_b64.b64encode(img_bytes).decode()}"
img_tags.append(
f'<img src="{data_uri}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
n_imgs = len(img_tags)
if n_imgs == 1:
# 1장: 본문 최상단에 배치
body_html = img_tags[0] + '\n' + body_html
else:
# 2~3장: 본문 블록 사이에 균등 분산 배치
block_pattern = _re.compile(
r'(<(?:p|h[1-6]|div|ul|ol|blockquote|table|section|article|figure)'
r'[\s>])',
_re.IGNORECASE,
)
blocks = block_pattern.split(body_html)
boundary_indices = [i for i in range(1, len(blocks), 2)]
if len(boundary_indices) >= n_imgs + 1:
# 균등 분산: spacing=0 방지를 위해 비율 기반 계산
insert_positions = [
int(len(boundary_indices) * (k + 1) / (n_imgs + 1))
for k in range(n_imgs)
]
# 중복 위치 제거 (spacing이 너무 좁을 때)
insert_positions = sorted(set(insert_positions))
for img_idx, pos in enumerate(reversed(insert_positions)):
bi = boundary_indices[min(pos, len(boundary_indices) - 1)]
img_tag_idx = min(len(img_tags) - 1, len(insert_positions) - 1 - img_idx)
blocks.insert(bi, '\n' + img_tags[img_tag_idx] + '\n')
body_html = ''.join(blocks)
else:
body_html = '\n'.join(img_tags) + '\n' + body_html
logger.info(f"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치")
else:
corner = article.get('corner', '')
# 제품리뷰: 원문 이미지 다수 수집하여 본문에 분산 배치
if corner == '제품리뷰':
source_images = _fetch_article_images(article, max_images=5)
if source_images:
import re as _re2
img_tags = []
for src_url in source_images:
img_tags.append(
f'<img src="{src_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" loading="lazy" />'
)
n_imgs = len(img_tags)
if n_imgs == 1:
body_html = img_tags[0] + '\n' + body_html
else:
block_pattern = _re2.compile(
r'(<(?:p|h[1-6]|div|ul|ol|blockquote|table|section|article|figure)'
r'[\s>])',
_re2.IGNORECASE,
)
blocks = block_pattern.split(body_html)
boundary_indices = [i for i in range(1, len(blocks), 2)]
if len(boundary_indices) >= n_imgs + 1:
insert_positions = [
int(len(boundary_indices) * (k + 1) / (n_imgs + 1))
for k in range(n_imgs)
]
insert_positions = sorted(set(insert_positions))
for img_idx, pos in enumerate(reversed(insert_positions)):
bi = boundary_indices[min(pos, len(boundary_indices) - 1)]
img_tag_idx = min(len(img_tags) - 1, len(insert_positions) - 1 - img_idx)
blocks.insert(bi, '\n' + img_tags[img_tag_idx] + '\n')
body_html = ''.join(blocks)
else:
body_html = '\n'.join(img_tags) + '\n' + body_html
logger.info(f"제품리뷰 원문 이미지 {n_imgs}장 본문 분산 배치")
else:
# 원문 이미지 없으면 기존 대표이미지 1장
image_url = fetch_featured_image(article)
if image_url:
img_tag = (
f'<img src="{image_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
body_html = img_tag + '\n' + body_html
else:
# 제품리뷰 외: 기존처럼 대표이미지 1장
image_url = fetch_featured_image(article)
if image_url:
img_tag = (
f'<img src="{image_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
body_html = img_tag + '\n' + body_html
html_parts.append(json_ld)
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
h2_count = body_html.lower().count('<h2')
toc_has_links = toc_html and '<a ' in toc_html and h2_count >= 3
if toc_has_links:
# 이미지 뒤, 본문 앞에 목차 삽입
toc_block = f'<div class="toc-wrapper">{toc_html}</div>\n'
body_html = _insert_toc_after_image(body_html, toc_block)
html_parts.append(body_html)
# 원문 출처 링크
sources = article.get('sources', [])
source_url = article.get('source_url', '')
source_name = article.get('source_name', '') or article.get('source', '')
if sources or source_url:
html_parts.append('<hr/>')
html_parts.append('<div class="source-info" style="margin:1.5em 0;padding:1em;'
'background:#f8f9fa;border-left:4px solid #ddd;border-radius:4px;'
'font-size:0.9em;color:#555;">')
html_parts.append('<b>📌 원문 출처</b><br/>')
seen = set()
if sources:
for src in sources:
url = src.get('url', '')
title = src.get('title', '') or url
if url and url not in seen:
seen.add(url)
html_parts.append(f'• <a href="{url}" target="_blank" rel="noopener">{title}</a><br/>')
if source_url and source_url not in seen:
label = source_name or source_url
html_parts.append(f'• <a href="{source_url}" target="_blank" rel="noopener">{label}</a><br/>')
html_parts.append('</div>')
if disclaimer:
html_parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
html_parts.append(f'<p class="disclaimer"><small>{disclaimer}</small></p>')
return '\n'.join(html_parts)
@@ -321,6 +909,22 @@ def log_published(article: dict, post_result: dict):
return record
def _cleanup_published_topic(article: dict):
"""발행 완료된 topic 파일을 topics/ 에서 삭제"""
import hashlib
topics_dir = DATA_DIR / 'topics'
topic_text = article.get('topic', '') or article.get('title', '')
if not topic_text:
return
topic_id = hashlib.md5(topic_text.encode()).hexdigest()[:8]
for f in topics_dir.glob(f'*_{topic_id}.json'):
try:
f.unlink()
logger.info(f"발행 완료 topic 파일 삭제: {f.name}")
except Exception as e:
logger.debug(f"topic 파일 삭제 실패: {e}")
def save_pending_review(article: dict, reason: str):
"""수동 검토 대기 글 저장"""
pending_dir = DATA_DIR / 'pending_review'
@@ -337,6 +941,46 @@ def load_pending_review_file(filepath: str) -> dict:
return json.load(f)
def validate_seo(article: dict) -> list[str]:
"""#10 발행 전 SEO 기본 요건 검증 — 경고 목록 반환"""
warnings = []
title = article.get('title', '') or ''
meta = article.get('meta', '') or ''
body = article.get('body', '') or ''
tags = article.get('tags', []) or []
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',') if t.strip()]
# 제목 길이 (30~70자 권장)
if len(title) < 15:
warnings.append(f"제목이 너무 짧음 ({len(title)}자, 최소 15자 권장)")
elif len(title) > 80:
warnings.append(f"제목이 너무 김 ({len(title)}자, 80자 이내 권장)")
# 메타 설명 (50~160자 권장)
if len(meta) < 30:
warnings.append(f"메타 설명이 너무 짧음 ({len(meta)}자, 최소 30자 권장)")
elif len(meta) > 160:
warnings.append(f"메타 설명이 너무 김 ({len(meta)}자, 160자 이내 권장)")
# H2 태그 (최소 2개 권장)
h2_count = body.lower().count('<h2')
if h2_count < 2:
warnings.append(f"H2 소제목이 부족 ({h2_count}개, 최소 2개 권장)")
# 태그 (최소 3개 권장)
if len(tags) < 3:
warnings.append(f"태그가 부족 ({len(tags)}개, 최소 3개 권장)")
# 본문 길이 (최소 500자)
import re as _re
text_only = _re.sub(r'<[^>]+>', '', body)
if len(text_only) < 500:
warnings.append(f"본문이 너무 짧음 ({len(text_only)}자, 최소 500자 권장)")
return warnings
# ─── 메인 발행 함수 ──────────────────────────────────
def publish(article: dict) -> bool:
@@ -349,6 +993,12 @@ def publish(article: dict) -> bool:
Returns: True(발행 성공) / False(수동 검토 대기)
"""
logger.info(f"발행 시도: {article.get('title', '')}")
# #10 SEO 검증
seo_warnings = validate_seo(article)
if seo_warnings:
logger.warning(f"SEO 경고: {'; '.join(seo_warnings)}")
safety_cfg = load_config('safety_keywords.json')
# 안전장치 검사
@@ -391,6 +1041,9 @@ def publish(article: dict) -> bool:
# 발행 이력 저장
log_published(article, post_result)
# 발행 완료된 topic 파일 정리
_cleanup_published_topic(article)
# Telegram 알림
title = article.get('title', '')
corner = article.get('corner', '')
@@ -411,6 +1064,11 @@ def approve_pending(filepath: str) -> bool:
article.pop('pending_reason', None)
article.pop('created_at', None)
# #10 SEO 검증
seo_warnings = validate_seo(article)
if seo_warnings:
logger.warning(f"SEO 경고 (승인 발행): {'; '.join(seo_warnings)}")
# 안전장치 우회하여 강제 발행
body_html, toc_html = markdown_to_html(article.get('body', ''))
body_html = insert_adsense_placeholders(body_html)

296
bots/reddit_collector.py Normal file
View File

@@ -0,0 +1,296 @@
"""
bots/reddit_collector.py
역할: Reddit 인기 서브레딧에서 트렌딩 주제 수집
API 키 불필요 — Reddit 공개 .json 엔드포인트 사용
수집된 주제는 data/reddit_topics/ 에 저장
"""
import hashlib
import json
import logging
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import requests
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
REDDIT_TOPICS_DIR = DATA_DIR / 'reddit_topics'
# 인기 서브레딧 목록 (다양한 카테고리)
DEFAULT_SUBREDDITS = [
# 기술/AI
{'name': 'technology', 'category': 'tech'},
{'name': 'artificial', 'category': 'ai'},
{'name': 'MachineLearning', 'category': 'ai'},
{'name': 'gadgets', 'category': 'product'},
# 과학/교육
{'name': 'science', 'category': 'science'},
{'name': 'explainlikeimfive', 'category': 'education'},
{'name': 'Futurology', 'category': 'tech'},
# 비즈니스/재테크
{'name': 'startups', 'category': 'business'},
{'name': 'personalfinance', 'category': 'finance'},
{'name': 'Entrepreneur', 'category': 'business'},
# 생활/여행
{'name': 'LifeProTips', 'category': 'lifestyle'},
{'name': 'travel', 'category': 'travel'},
{'name': 'foodhacks', 'category': 'food'},
# 트렌드/흥미
{'name': 'todayilearned', 'category': 'interesting'},
{'name': 'worldnews', 'category': 'news'},
]
HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)',
}
# Reddit 카테고리 → 블로그 코너 매핑
CATEGORY_CORNER_MAP = {
'tech': 'AI인사이트',
'ai': 'AI인사이트',
'product': '제품리뷰',
'science': '팩트체크',
'education': '생활꿀팁',
'business': '스타트업',
'finance': '재테크',
'lifestyle': '생활꿀팁',
'travel': '여행맛집',
'food': '여행맛집',
'interesting': 'AI인사이트',
'news': '팩트체크',
}
def _fetch_subreddit_top(subreddit: str, limit: int = 5, time_filter: str = 'day') -> list[dict]:
"""
서브레딧의 인기 포스트 가져오기.
time_filter: hour, day, week, month, year, all
"""
url = f'https://www.reddit.com/r/{subreddit}/top.json'
params = {'limit': limit, 't': time_filter}
try:
resp = requests.get(url, headers=HEADERS, params=params, timeout=15)
resp.raise_for_status()
data = resp.json()
posts = []
for child in data.get('data', {}).get('children', []):
post = child.get('data', {})
# 영상/이미지 전용 포스트는 제외 (텍스트 기반 주제만)
if post.get('is_video') or post.get('post_hint') == 'image':
# 제목은 여전히 주제로 활용 가능
pass
# 이미지 추출: preview > thumbnail > url 순
image_url = ''
preview = post.get('preview', {})
if preview:
images = preview.get('images', [])
if images:
source = images[0].get('source', {})
image_url = source.get('url', '').replace('&amp;', '&')
if not image_url:
thumb = post.get('thumbnail', '')
if thumb and thumb.startswith('http'):
image_url = thumb.replace('&amp;', '&')
# i.redd.it 직접 이미지 링크
if not image_url:
post_url = post.get('url', '')
if post_url and any(post_url.endswith(ext) for ext in ('.jpg', '.jpeg', '.png', '.webp', '.gif')):
image_url = post_url
posts.append({
'title': post.get('title', ''),
'selftext': (post.get('selftext', '') or '')[:500],
'score': post.get('score', 0),
'num_comments': post.get('num_comments', 0),
'subreddit': subreddit,
'permalink': post.get('permalink', ''),
'url': post.get('url', ''),
'created_utc': post.get('created_utc', 0),
'is_video': post.get('is_video', False),
'post_hint': post.get('post_hint', ''),
'image_url': image_url,
'domain': post.get('domain', ''),
})
return posts
except Exception as e:
logger.warning(f'r/{subreddit} 수집 실패: {e}')
return []
def _clean_title(title: str) -> str:
"""Reddit 제목 정리 — 불필요한 태그/기호 제거."""
title = re.sub(r'\[.*?\]', '', title).strip()
title = re.sub(r'\(.*?\)', '', title).strip()
title = re.sub(r'\s+', ' ', title).strip()
return title
def _is_duplicate(title: str, existing: list[dict]) -> bool:
"""제목 유사도 기반 중복 검사 (단어 60% 이상 겹치면 중복)."""
new_words = set(title.lower().split())
if not new_words:
return False
for item in existing:
old_words = set(item.get('title', '').lower().split())
if not old_words:
continue
overlap = len(new_words & old_words) / max(len(new_words), 1)
if overlap > 0.6:
return True
return False
def collect(
subreddits: Optional[list[dict]] = None,
top_n: int = 3,
time_filter: str = 'day',
min_score: int = 100,
) -> list[dict]:
"""
여러 서브레딧에서 인기 주제 수집.
Args:
subreddits: 서브레딧 목록 [{'name': ..., 'category': ...}]
top_n: 서브레딧당 가져올 포스트 수
time_filter: 기간 필터 (day/week/month)
min_score: 최소 업보트 수
Returns:
정렬된 주제 리스트
"""
if subreddits is None:
subreddits = DEFAULT_SUBREDDITS
REDDIT_TOPICS_DIR.mkdir(parents=True, exist_ok=True)
all_topics = []
for sub_info in subreddits:
sub_name = sub_info['name']
category = sub_info.get('category', 'tech')
logger.info(f'r/{sub_name} 수집 중...')
posts = _fetch_subreddit_top(sub_name, limit=top_n, time_filter=time_filter)
for post in posts:
if post['score'] < min_score:
continue
title = _clean_title(post['title'])
if not title or len(title) < 10:
continue
if _is_duplicate(title, all_topics):
continue
corner = CATEGORY_CORNER_MAP.get(category, 'AI인사이트')
topic = {
'topic': title,
'description': post['selftext'][:300] if post['selftext'] else title,
'source': 'reddit',
'source_url': f"https://www.reddit.com{post['permalink']}",
'source_name': f"r/{sub_name}",
'source_image': post.get('image_url', ''),
'subreddit': sub_name,
'reddit_category': category,
'corner': corner,
'score': post['score'],
'num_comments': post['num_comments'],
'is_video': post['is_video'],
'domain': post.get('domain', ''),
'original_url': post.get('url', ''),
'collected_at': datetime.now(timezone.utc).isoformat(),
'created_utc': post['created_utc'],
}
all_topics.append(topic)
# Reddit rate limit 준수 (1.5초 간격)
time.sleep(1.5)
# 업보트 수 기준 내림차순 정렬
all_topics.sort(key=lambda x: x['score'], reverse=True)
# 저장
saved = _save_topics(all_topics)
logger.info(f'Reddit 수집 완료: {len(saved)}개 주제')
return saved
def _save_topics(topics: list[dict]) -> list[dict]:
"""수집된 주제를 개별 JSON 파일로 저장."""
saved = []
today = datetime.now().strftime('%Y%m%d')
for topic in topics:
topic_id = hashlib.md5(topic['topic'].encode()).hexdigest()[:8]
filename = f'{today}_reddit_{topic_id}.json'
filepath = REDDIT_TOPICS_DIR / filename
# 이미 존재하면 스킵
if filepath.exists():
continue
topic['filename'] = filename
filepath.write_text(json.dumps(topic, ensure_ascii=False, indent=2), encoding='utf-8')
saved.append(topic)
return saved
def load_topics() -> list[dict]:
"""저장된 Reddit 주제 로드 (최신순)."""
if not REDDIT_TOPICS_DIR.exists():
return []
topics = []
for f in sorted(REDDIT_TOPICS_DIR.glob('*.json'), reverse=True):
try:
topic = json.loads(f.read_text(encoding='utf-8'))
topic['_filepath'] = str(f)
topic['_filename'] = f.name
topics.append(topic)
except Exception:
continue
return topics
def get_display_list(topics: list[dict], limit: int = 20) -> str:
"""텔레그램 표시용 주제 리스트 생성."""
if not topics:
return '수집된 Reddit 주제가 없습니다.'
lines = ['🔥 <b>Reddit 트렌딩 주제</b>\n']
for i, t in enumerate(topics[:limit], 1):
score = t.get('score', 0)
if score >= 10000:
score_str = f'{score / 1000:.1f}k'
elif score >= 1000:
score_str = f'{score / 1000:.1f}k'
else:
score_str = str(score)
comments = t.get('num_comments', 0)
sub = t.get('source_name', '')
title = t.get('topic', '')[:60]
corner = t.get('corner', '')
has_img = '🖼' if t.get('source_image') else ''
lines.append(
f'{i}. ⬆️{score_str} 💬{comments} {has_img} {title}\n'
f' {sub} | {corner}'
)
lines.append(f'\n📌 총 {len(topics)}개 | 상위 {min(limit, len(topics))}개 표시')
lines.append('선택: 번호를 눌러 [📝 블로그] [🎬 쇼츠] [📝+🎬 둘 다]')
return '\n'.join(lines)

File diff suppressed because it is too large Load Diff

View File

@@ -47,10 +47,20 @@ CAPTION_TEMPLATES = {
# Corner → caption template mapping
CORNER_CAPTION_MAP = {
# 현재 블로그 코너
'AI인사이트': 'brand_4thpath',
'여행맛집': 'tiktok_viral',
'스타트업': 'hormozi',
'제품리뷰': 'hormozi',
'생활꿀팁': 'tiktok_viral',
'재테크': 'hormozi',
'TV로보는세상': 'tiktok_viral',
'건강정보': 'brand_4thpath',
'팩트체크': 'brand_4thpath',
# 레거시 코너 (하위 호환)
'쉬운세상': 'hormozi',
'숨은보물': 'tiktok_viral',
'바이브리포트': 'hormozi',
'팩트체크': 'brand_4thpath',
'한컷': 'tiktok_viral',
'웹소설': 'brand_4thpath',
}
@@ -375,6 +385,7 @@ def render_captions(
ass_content = header + '\n'.join(events) + '\n'
ass_path.write_text(ass_content, encoding='utf-8-sig') # BOM for Windows compatibility
logger.info(f'ASS 자막 생성: {ass_path.name} ({len(timestamps)}단어, {len(lines)}라인)')
return ass_path
# ── Standalone test ──────────────────────────────────────────────
@@ -429,4 +440,3 @@ if __name__ == '__main__':
assert exists and size > 0, "ASS 파일 생성 실패"
print("\n✅ 모든 테스트 통과")
return ass_path

View File

@@ -171,7 +171,7 @@ def _extract_via_claude_api(post_text: str) -> Optional[dict]:
msg = client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=512,
max_tokens=1024,
messages=[{'role': 'user', 'content': prompt}],
)
raw = msg.content[0].text
@@ -198,14 +198,20 @@ def _extract_rule_based(article: dict) -> dict:
if not hook.endswith('?'):
hook = f'{title[:20]}... 알고 계셨나요?'
# body: KEY_POINTS 앞 3개
body = [p.strip('- ').strip() for p in key_points[:3]] if key_points else [title]
# body: KEY_POINTS 앞 7개 (35-45초 분량)
body = [p.strip('- ').strip() for p in key_points[:7]] if key_points else [title]
# closer: 코너별 CTA
cta_map = {
'쉬운세상': '블로그에서 더 자세히 확인해보세요.',
'숨은보물': '이 꿀팁, 주변에 공유해보세요.',
'웹소설': '전편 블로그에서 읽어보세요.',
'AI인사이트': '더 깊은 AI 이야기, 블로그에서 확인하세요.',
'여행맛집': '숨은 맛집 더 보기, 블로그 링크 클릭!',
'스타트업': '스타트업 트렌드, 블로그에서 자세히 보세요.',
'제품리뷰': '실사용 후기 전문은 블로그에서 확인하세요.',
'생활꿀팁': '이 꿀팁, 주변에 공유해보세요.',
'재테크': '재테크 꿀팁 더 보기, 블로그에서 확인!',
'TV로보는세상': '화제의 장면, 블로그에서 더 보세요.',
'건강정보': '건강 정보 더 보기, 블로그에서 확인하세요.',
'팩트체크': '팩트체크 전문은 블로그에서 확인하세요.',
}
closer = cta_map.get(corner, '구독하고 다음 편도 기대해주세요.')

View File

@@ -25,6 +25,45 @@ BASE_DIR = Path(__file__).parent.parent.parent
PEXELS_VIDEO_URL = 'https://api.pexels.com/videos/search'
PIXABAY_VIDEO_URL = 'https://pixabay.com/api/videos/'
# 스크린 녹화/UI/텍스트 영상 제외 키워드
_SCREEN_BLOCK_TAGS = {
'screen recording', 'screenshot', 'tutorial', 'demo', 'interface',
'typing', 'chatgpt', 'chatbot', 'website', 'browser', 'desktop',
'laptop screen', 'phone screen', 'app', 'software', 'code', 'coding',
'monitor', 'computer screen', 'ui', 'ux', 'dashboard',
}
# 검색어에서 제외할 키워드 (스크린 녹화 유발)
_SEARCH_EXCLUDE = {
'chatgpt', 'ai chat', 'gpt', 'openai', 'claude', 'gemini',
'software', 'app', 'website', 'browser', 'code',
}
def _sanitize_keyword(keyword: str) -> str:
"""스크린 녹화 유발 키워드를 자연 영상 키워드로 변환."""
kw_lower = keyword.lower()
for excl in _SEARCH_EXCLUDE:
if excl in kw_lower:
# AI/기술 키워드 → 실사 대체 키워드
replacements = {
'chatgpt': 'futuristic technology',
'ai chat': 'artificial intelligence robot',
'gpt': 'digital innovation',
'openai': 'technology innovation',
'claude': 'digital brain',
'gemini': 'space stars',
'software': 'digital technology',
'app': 'smartphone lifestyle',
'website': 'modern office',
'browser': 'modern workspace',
'code': 'digital network',
}
for k, v in replacements.items():
if k in kw_lower:
return v
return keyword
def _load_config() -> dict:
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
@@ -47,6 +86,8 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
import urllib.parse
import urllib.request
keyword = _sanitize_keyword(keyword)
params = urllib.parse.urlencode({
'query': keyword,
'orientation': 'portrait' if prefer_vertical else 'landscape',
@@ -55,7 +96,10 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
})
req = urllib.request.Request(
f'{PEXELS_VIDEO_URL}?{params}',
headers={'Authorization': api_key},
headers={
'Authorization': api_key,
'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)',
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
@@ -87,18 +131,27 @@ def _search_pixabay(keyword: str, api_key: str, prefer_vertical: bool = True) ->
"""Pixabay Video API 검색 → [{url, width, height, duration}, ...] 반환."""
import urllib.parse
keyword = _sanitize_keyword(keyword)
params = urllib.parse.urlencode({
'key': api_key,
'q': keyword,
'video_type': 'film',
'per_page': 10,
})
req = urllib.request.Request(f'{PIXABAY_VIDEO_URL}?{params}')
req = urllib.request.Request(
f'{PIXABAY_VIDEO_URL}?{params}',
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)'},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
results = []
for hit in data.get('hits', []):
# 태그 기반 스크린녹화/UI 영상 필터링
tags = hit.get('tags', '').lower()
if any(block in tags for block in _SCREEN_BLOCK_TAGS):
continue
videos = hit.get('videos', {})
# medium 우선
for quality in ('medium', 'large', 'small', 'tiny'):
@@ -151,6 +204,7 @@ def _prepare_clip(input_path: Path, output_path: Path, duration: float = 6.0) ->
),
'-r', '30',
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', # 스톡 클립 오디오 제거
str(output_path),
]
@@ -278,6 +332,10 @@ def fetch_clips(
expressions = manifest.get('expressions', [])
char_pose = manifest.get('pose', manifest.get('character', {}).get('default_pose', ''))
# MotionEngine: 정지 이미지에 7가지 모션 패턴 적용 (직전 2개 제외 자동 선택)
from shorts.motion_engine import MotionEngine
motion = MotionEngine()
result_clips: list[Path] = []
# 1. 사용자 제공 비디오 클립
@@ -286,23 +344,24 @@ def fetch_clips(
if _prepare_clip(Path(user_clip), out):
result_clips.append(out)
# 2. 사용자 제공 이미지 → Ken Burns
# 2. 사용자 제공 이미지 → MotionEngine (7패턴 자동 선택)
for i, user_img in enumerate(manifest.get('user_images', [])[:max_clips]):
if len(result_clips) >= max_clips:
break
out = clips_dir / f'clip_img_{i+1:02d}.mp4'
if _kenburns_image(Path(user_img), out):
result_clips.append(out)
result_path = motion.apply(str(user_img), duration=6.0, output_path=str(out))
if result_path:
result_clips.append(Path(result_path))
# 3. 캐릭터 에셋 + 배경 합성
background = manifest.get('background', '')
if background and Path(background).exists() and len(result_clips) < max_clips:
# 배경 이미지 → Ken Burns 클립 (표정별 합성)
# 배경 이미지 → MotionEngine 클립 (표정별 합성)
for seg_idx, expr_png in enumerate(expressions[:3]):
if len(result_clips) >= max_clips:
break
out_bg = clips_dir / f'clip_bg_{seg_idx+1:02d}.mp4'
if _kenburns_image(Path(background), out_bg):
if motion.apply(str(background), duration=6.0, output_path=str(out_bg)):
# 표정 오버레이
if expr_png and Path(expr_png).exists():
out_char = clips_dir / f'clip_char_{seg_idx+1:02d}.mp4'
@@ -374,8 +433,9 @@ def fetch_clips(
while len(result_clips) < min_clips:
stock_idx += 1
out = clips_dir / f'clip_fallback_{stock_idx:02d}.mp4'
if _kenburns_image(fallback_img, out):
result_clips.append(out)
result_path = motion.apply(str(fallback_img), duration=6.0, output_path=str(out))
if result_path:
result_clips.append(Path(result_path))
else:
break

View File

@@ -396,7 +396,15 @@ def _tts_edge(text: str, output_path: Path, cfg: dict) -> list[dict]:
communicate = edge_tts.Communicate(text, voice, rate=rate)
await communicate.save(str(mp3_tmp))
asyncio.get_event_loop().run_until_complete(_generate())
try:
loop = asyncio.get_running_loop()
# 이미 루프 안에 있으면 새 스레드에서 실행
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
pool.submit(lambda: asyncio.run(_generate())).result()
except RuntimeError:
# 루프 없음 — 직접 실행
asyncio.run(_generate())
# mp3 → wav
_mp3_to_wav(mp3_tmp, output_path)

View File

@@ -128,6 +128,7 @@ def _concat_with_xfade(clips: list[Path], output: Path, crossfade: float, ffmpeg
'-filter_complex', filter_complex,
'-map', prev_label,
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', '-r', '30',
str(output),
]
@@ -151,6 +152,7 @@ def _concat_simple(clips: list[Path], output: Path, ffmpeg: str) -> bool:
'-f', 'concat', '-safe', '0',
'-i', str(list_file),
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', '-r', '30',
str(output),
]
@@ -240,8 +242,11 @@ def _assemble_final(
f'apad=pad_dur=0.2' # 루프 최적화: 0.2s 무음
),
'-c:v', codec, '-crf', str(crf), '-preset', 'medium',
'-pix_fmt', 'yuv420p',
'-profile:v', 'high', '-level', '4.0',
'-c:a', audio_codec, '-b:a', audio_bitrate,
'-r', str(vid_cfg.get('fps', 30)),
'-movflags', '+faststart',
'-shortest',
str(output),
]
@@ -266,7 +271,9 @@ def _rerender_smaller(src: Path, dst: Path, ffmpeg: str) -> bool:
cmd = [
ffmpeg, '-y', '-i', str(src),
'-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
'-pix_fmt', 'yuv420p', '-profile:v', 'high', '-level', '4.0',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
str(dst),
]
try:
@@ -339,7 +346,7 @@ def assemble(
tmp_cleanup = work_dir is None
if work_dir is None:
work_dir = output_dir / f'_work_{timestamp}'
work_dir.mkdir(parents=True, exist_ok=True)
work_dir.mkdir(parents=True, exist_ok=True)
try:
# ── 루프 최적화: 클립 목록 끝에 첫 클립 추가 ──────────────

View File

@@ -40,31 +40,56 @@ def _load_config() -> dict:
def _get_youtube_service():
"""YouTube Data API v3 서비스 객체 생성 (기존 OAuth token.json 재사용)."""
"""YouTube Data API v3 서비스 객체 생성 (token.json 우선, env fallback)."""
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
if not TOKEN_PATH.exists():
raise RuntimeError(f'OAuth 토큰 없음: {TOKEN_PATH} — scripts/get_token.py 실행 필요')
creds = None
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
client_id = os.environ.get('GOOGLE_CLIENT_ID', creds_data.get('client_id', ''))
client_secret = os.environ.get('GOOGLE_CLIENT_SECRET', creds_data.get('client_secret', ''))
# 1) token.json 파일 우선
if TOKEN_PATH.exists():
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
client_id = os.environ.get('GOOGLE_CLIENT_ID', creds_data.get('client_id', ''))
client_secret = os.environ.get('GOOGLE_CLIENT_SECRET', creds_data.get('client_secret', ''))
creds = Credentials(
token=creds_data.get('token'),
refresh_token=creds_data.get('refresh_token') or os.environ.get('GOOGLE_REFRESH_TOKEN'),
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
)
creds = Credentials(
token=creds_data.get('token'),
refresh_token=creds_data.get('refresh_token') or os.environ.get('GOOGLE_REFRESH_TOKEN'),
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
scopes=YOUTUBE_SCOPES,
)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
# 갱신된 토큰 저장
creds_data['token'] = creds.token
TOKEN_PATH.write_text(json.dumps(creds_data, indent=2), encoding='utf-8')
# 2) .env의 YOUTUBE_REFRESH_TOKEN으로 직접 생성 (Docker 환경 대응)
if not creds:
refresh_token = os.environ.get('YOUTUBE_REFRESH_TOKEN', '') or os.environ.get('GOOGLE_REFRESH_TOKEN', '')
client_id = os.environ.get('YOUTUBE_CLIENT_ID', '') or os.environ.get('GOOGLE_CLIENT_ID', '')
client_secret = os.environ.get('YOUTUBE_CLIENT_SECRET', '') or os.environ.get('GOOGLE_CLIENT_SECRET', '')
if not all([refresh_token, client_id, client_secret]):
raise RuntimeError(
'OAuth 인증 정보 없음: token.json 또는 '
'YOUTUBE_REFRESH_TOKEN/YOUTUBE_CLIENT_ID/YOUTUBE_CLIENT_SECRET 환경변수 필요'
)
creds = Credentials(
token=None,
refresh_token=refresh_token,
token_uri='https://oauth2.googleapis.com/token',
client_id=client_id,
client_secret=client_secret,
)
logger.info('token.json 없음 — YOUTUBE_REFRESH_TOKEN 환경변수로 인증')
# 토큰 갱신
if creds.expired or not creds.token:
if creds.refresh_token:
creds.refresh(Request())
# token.json이 있으면 갱신된 토큰 저장
if TOKEN_PATH.exists():
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
creds_data['token'] = creds.token
TOKEN_PATH.write_text(json.dumps(creds_data, indent=2), encoding='utf-8')
else:
raise RuntimeError('refresh_token 없음 — 재인증 필요')
return build('youtube', 'v3', credentials=creds)

View File

@@ -143,7 +143,8 @@ def _is_converted(article_id: str) -> bool:
# ─── 파이프라인 ───────────────────────────────────────────────
def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) -> ShortsResult:
def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None,
skip_upload: bool = False) -> ShortsResult:
"""
블로그 글 → 쇼츠 영상 생산 + (선택) YouTube 업로드.
@@ -151,6 +152,7 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
article: article dict
dry_run: True이면 렌더링까지만 (업로드 생략)
cfg: shorts_config.json dict (None이면 자동 로드)
skip_upload: True이면 영상 렌더링까지만 (업로드는 별도 승인 후 진행)
Returns:
ShortsResult
@@ -160,7 +162,8 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
from shorts.stock_fetcher import fetch_clips
from shorts.tts_engine import generate_tts
from shorts.caption_renderer import render_captions
from shorts.video_assembler import assemble
from shorts.video_assembler import ResilientAssembler
from shorts.hook_optimizer import HookOptimizer
if cfg is None:
cfg = _load_config()
@@ -192,6 +195,27 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
manifest = resolve(article, script=script, cfg=cfg)
result.steps_completed.append('script_extract')
# ── STEP 1.5: Hook Optimization (LLM 연동) ──────────────
hook_optimizer = HookOptimizer(threshold=70)
original_hook = script.get('hook', '')
# LLM 함수 생성 — 기존 엔진 로더 활용
llm_fn = None
try:
from engine_loader import EngineLoader
writer = EngineLoader().get_writer()
if writer:
def _hook_llm(prompt: str) -> str:
return writer.write(prompt).strip()
llm_fn = _hook_llm
except Exception as e:
logger.warning(f'[{article_id}] 훅 LLM 로드 실패 (규칙 기반으로 진행): {e}')
optimized_hook = hook_optimizer.optimize(original_hook, article, llm_fn=llm_fn)
if optimized_hook != original_hook:
script['hook'] = optimized_hook
logger.info(f'[{article_id}] 훅 최적화: "{original_hook[:20]}""{optimized_hook[:20]}"')
# ── STEP 2: Visual Sourcing ──────────────────────────────
logger.info(f'[{article_id}] STEP 2: Visual Sourcing')
clips = fetch_clips(script, manifest, clips_dir, ts, cfg=cfg)
@@ -227,12 +251,14 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
logger.info(f'[{article_id}] STEP 4: Caption Rendering')
from shorts.tts_engine import _get_wav_duration
wav_dur = _get_wav_duration(tts_wav)
ass_path = render_captions(script, timestamps, captions_dir, ts, wav_dur, cfg=cfg)
corner = article.get('corner', '')
ass_path = render_captions(script, timestamps, captions_dir, ts, wav_dur, cfg=cfg, corner=corner)
result.steps_completed.append('caption_render')
# ── STEP 5: Video Assembly ───────────────────────────────
logger.info(f'[{article_id}] STEP 5: Video Assembly')
video_path = assemble(clips, tts_wav, ass_path, rendered_dir, ts, cfg=cfg)
# ── STEP 5: Video Assembly (ResilientAssembler + GPU 자동 감지) ──
logger.info(f'[{article_id}] STEP 5: Video Assembly (Resilient)')
assembler = ResilientAssembler(cfg=cfg)
video_path = assembler.assemble_resilient(clips, tts_wav, ass_path, rendered_dir, ts)
result.video_path = str(video_path)
result.steps_completed.append('video_assemble')
@@ -245,6 +271,11 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
result.success = True
return result
if skip_upload:
logger.info(f'[{article_id}] STEP 6: 건너뜀 (승인 대기 — skip_upload)')
result.success = True
return result
logger.info(f'[{article_id}] STEP 6: YouTube Upload')
from shorts.youtube_uploader import upload
upload_record = upload(video_path, article, script, ts, cfg=cfg)

View File

@@ -9,7 +9,7 @@
"tagline": "어렵지 않아요, 그냥 읽어봐요",
"active": true,
"phase": 1,
"labels": ["AI인사이트", "여행맛집", "스타트업", "제품리뷰", "생활꿀팁", "앱추천", "재테크절약", "팩트체크"]
"labels": ["AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"]
}
]
}

View File

@@ -2,8 +2,9 @@
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
"_updated": "2026-03-29",
"writing": {
"provider": "gemini",
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
"provider": "claude",
"fallback_chain": ["gemini", "groq"],
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키, groq=Groq API키",
"options": {
"openclaw": {
"agent_name": "blog-writer",
@@ -20,8 +21,9 @@
},
"claude": {
"api_key_env": "ANTHROPIC_API_KEY",
"base_url": "http://192.168.0.17:8317/api/provider/claude",
"model": "claude-opus-4-6",
"max_tokens": 4096,
"max_tokens": 8192,
"temperature": 0.7
},
"gemini": {
@@ -29,6 +31,12 @@
"model": "gemini-2.5-flash",
"max_tokens": 4096,
"temperature": 0.7
},
"groq": {
"api_key_env": "GROQ_API_KEY",
"model": "llama-3.3-70b-versatile",
"max_tokens": 4096,
"temperature": 0.7
}
}
},

View File

@@ -4,7 +4,7 @@
"korean_relevance": {
"max": 30,
"description": "한국 독자 관련성",
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자"]
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자", "AI", "GPT", "ChatGPT", "Claude", "Gemini", "Apple", "Google", "iPhone", "갤럭시", "Netflix", "넷플릭스", "YouTube"]
},
"freshness": {
"max": 20,
@@ -63,7 +63,7 @@
{
"id": "clickbait",
"description": "클릭베이트성 주제",
"patterns": ["충격", "경악", "난리", "ㅋㅋ", "ㅠㅠ", "대박", "레전드", "역대급"]
"patterns": ["경악", "난리", "ㅋㅋ", "ㅠㅠ"]
}
],
"evergreen_keywords": [

View File

@@ -12,7 +12,7 @@
"legal_keywords": [
"불법", "위법", "처벌", "벌금", "징역", "기소"
],
"always_manual_review": ["팩트체크", "재테크절약"],
"always_manual_review": ["팩트체크", "재테크"],
"min_sources_required": 2,
"min_quality_score_for_auto": 101
}

View File

@@ -44,13 +44,15 @@
}
},
"corner_character_map": {
"쉬운세상": "tech_blog",
"숨은보물": "tech_blog",
"바이브리포트": "tech_blog",
"팩트체크": "tech_blog",
"한컷": "tech_blog",
"웹소설": "fourth_path",
"철학": "fourth_path"
"AI인사이트": "tech_blog",
"여행맛집": "tech_blog",
"스타트업": "tech_blog",
"제품리뷰": "tech_blog",
"생활꿀팁": "tech_blog",
"앱추천": "tech_blog",
"재테크절약": "tech_blog",
"재테크": "tech_blog",
"팩트체크": "tech_blog"
},
"character_overlay": {
"enabled": true,
@@ -82,8 +84,8 @@
"pexels_api_key_env": "PEXELS_API_KEY",
"pixabay_api_key_env": "PIXABAY_API_KEY",
"orientation": "portrait",
"min_clips": 3,
"max_clips": 5,
"min_clips": 5,
"max_clips": 8,
"prefer_vertical": true
},
@@ -100,7 +102,7 @@
"google_cloud": {
"credentials_env": "GOOGLE_APPLICATION_CREDENTIALS",
"voice_name": "ko-KR-Neural2-C",
"speaking_rate": 1.1
"speaking_rate": 1.0
},
"edge_tts": {
"voice": "ko-KR-SunHiNeural",
@@ -145,5 +147,5 @@
"daily_upload_limit": 6
},
"corners_eligible": ["쉬운세상", "숨은보물", "바이브리포트", "한컷", "웹소설", "철학"]
"corners_eligible": ["AI인사이트", "여행맛집", "스타트업", "제품리뷰", "생활꿀팁", "앱추천", "재테크절약", "재테크", "팩트체크"]
}

View File

@@ -7,6 +7,9 @@
{ "name": "테크크런치 AI", "url": "https://techcrunch.com/category/artificial-intelligence/feed/", "category": "AI인사이트", "trust_level": "high" },
{ "name": "MIT 테크리뷰", "url": "https://www.technologyreview.com/feed/", "category": "AI인사이트", "trust_level": "high" },
{ "name": "전자신문", "url": "https://www.etnews.com/rss/rss.xml", "category": "AI인사이트", "trust_level": "high" },
{ "name": "딥러닝 뉴스", "url": "https://news.google.com/rss/search?q=AI+인공지능&hl=ko&gl=KR&ceid=KR:ko", "category": "AI인사이트", "trust_level": "medium" },
{ "name": "Ars Technica AI", "url": "https://feeds.arstechnica.com/arstechnica/technology-lab", "category": "AI인사이트", "trust_level": "high" },
{ "name": "OpenAI Blog", "url": "https://openai.com/blog/rss.xml", "category": "AI인사이트", "trust_level": "high" },
{ "name": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
{ "name": "플래텀", "url": "https://platum.kr/feed", "category": "스타트업", "trust_level": "high" },
@@ -14,49 +17,70 @@
{ "name": "한국경제 IT", "url": "https://www.hankyung.com/feed/it", "category": "스타트업", "trust_level": "high" },
{ "name": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
{ "name": "TheVC 뉴스", "url": "https://news.google.com/rss/search?q=스타트업+투자유치&hl=ko&gl=KR&ceid=KR:ko", "category": "스타트업", "trust_level": "medium" },
{ "name": "연합뉴스 여행", "url": "https://www.yna.co.kr/rss/travel.xml", "category": "여행맛집", "trust_level": "high" },
{ "name": "경향신문 여행", "url": "https://www.khan.co.kr/rss/rssdata/kh_travel.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "한국관광공사", "url": "https://kto.visitkorea.or.kr/rss/rss.kto", "category": "여행맛집", "trust_level": "medium" },
{ "name": "대한항공 뉴스", "url": "https://www.koreanair.com/content/koreanair/global/en/footer/about-korean-air/news-and-pr/press-releases.rss.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "론리플래닛", "url": "https://www.lonelyplanet.com/news/feed", "category": "여행맛집", "trust_level": "medium" },
{ "name": "국내여행 맛집", "url": "https://news.google.com/rss/search?q=국내여행+맛집&hl=ko&gl=KR&ceid=KR:ko", "category": "여행맛집", "trust_level": "medium" },
{ "name": "마이리얼트립 블로그","url": "https://blog.myrealtrip.com/feed", "category": "여행맛집", "trust_level": "medium" },
{ "name": "트래블바이크뉴스", "url": "https://www.travelbike.kr/rss/allArticle.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "스포츠조선 연예", "url": "https://sports.chosun.com/site/data/rss/rss.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "연합뉴스 연예", "url": "https://www.yna.co.kr/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
{ "name": "한국경제 연예", "url": "https://www.hankyung.com/feed/entertainment", "category": "TV로보는세상","trust_level": "high" },
{ "name": "MBC 연예", "url": "https://imnews.imbc.com/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
{ "name": "TV리포트", "url": "https://www.tvreport.co.kr/rss/allArticle.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "OSEN 연예", "url": "https://www.osen.co.kr/rss/osen.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "뽐뿌 뉴스", "url": "https://www.ppomppu.co.kr/rss.php?id=news", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "Wired", "url": "https://www.wired.com/feed/rss", "category": "제품리뷰", "trust_level": "high" },
{ "name": "CNET", "url": "https://www.cnet.com/rss/news/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "9to5Google", "url": "https://9to5google.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "9to5Mac", "url": "https://9to5mac.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "XDA Developers", "url": "https://www.xda-developers.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Android Authority", "url": "https://www.androidauthority.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "제품리뷰", "trust_level": "high" },
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "조선일보 라이프", "url": "https://www.chosun.com/arc/outboundfeeds/rss/category/life/", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "생활꿀팁 구글뉴스", "url": "https://news.google.com/rss/search?q=생활+꿀팁+절약&hl=ko&gl=KR&ceid=KR:ko", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "중앙일보 라이프", "url": "https://rss.joins.com/joins_life_list.xml", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "Lifehacker", "url": "https://lifehacker.com/feed/rss", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "앱추천", "trust_level": "medium" },
{ "name": "테크크런치 앱", "url": "https://techcrunch.com/category/apps/feed/", "category": "앱추천", "trust_level": "high" },
{ "name": "앱스토리", "url": "https://www.appstory.co.kr/rss/rss.xml", "category": "앱추천", "trust_level": "medium" },
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "건강정보", "trust_level": "high" },
{ "name": "연합뉴스 건강", "url": "https://www.yna.co.kr/rss/health.xml", "category": "건강정보", "trust_level": "high" },
{ "name": "메디게이트뉴스", "url": "https://www.medigatenews.com/rss/rss.xml", "category": "건강정보", "trust_level": "medium" },
{ "name": "코메디닷컴", "url": "https://kormedi.com/feed/", "category": "건강정보", "trust_level": "medium" },
{ "name": "건강 구글뉴스", "url": "https://news.google.com/rss/search?q=건강+의료&hl=ko&gl=KR&ceid=KR:ko", "category": "건강정보", "trust_level": "medium" },
{ "name": "매일경제 IT", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크절약", "trust_level": "high" },
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크절약", "trust_level": "high" },
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크절약", "trust_level": "high" },
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크절약", "trust_level": "medium" },
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크절약", "trust_level": "high" },
{ "name": "매일경제 금융", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크", "trust_level": "high" },
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크", "trust_level": "high" },
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크", "trust_level": "high" },
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크", "trust_level": "medium" },
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "조선비즈 경제", "url": "https://biz.chosun.com/site/data/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "이데일리 금융", "url": "https://www.edaily.co.kr/rss/rss.asp?media_key=20", "category": "재테크", "trust_level": "high" },
{ "name": "절약 구글뉴스", "url": "https://news.google.com/rss/search?q=절약+재테크+재테크팁&hl=ko&gl=KR&ceid=KR:ko","category": "재테크", "trust_level": "medium" },
{ "name": "연합뉴스 팩트체크", "url": "https://www.yna.co.kr/rss/factcheck.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "SBS 뉴스", "url": "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=01&plink=RSSREADER", "category": "팩트체크", "trust_level": "high" },
{ "name": "KBS 뉴스", "url": "https://news.kbs.co.kr/rss/rss.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" }
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "서울신문", "url": "https://www.seoul.co.kr/xml/rss/rss_main.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "뉴스타파", "url": "https://newstapa.org/feed", "category": "팩트체크", "trust_level": "high" }
],
"x_keywords": [
"AI 사용법",
"ChatGPT 활용",
"Claude 사용",
"인공지능 추천",
"앱 추천",
"생활꿀팁",
"맛집 추천",
"스타트업 소식",
"재테크 방법",
"쇼핑 추천"
"AI 사용법", "ChatGPT 활용", "Claude 사용", "인공지능 추천",
"생활꿀팁", "맛집 추천", "스타트업 소식", "재테크 방법",
"가성비 제품", "여행 꿀팁", "절약 방법", "팩트체크",
"가짜뉴스", "건강 정보", "드라마 추천"
],
"github_trending": {
"url": "https://github.com/trending",

82
docs/ai-integration.md Normal file
View File

@@ -0,0 +1,82 @@
# AI 연동 설정
## 글 작성 (Writer)
### 우선순위 (Fallback 순서)
1. **Claude Opus 4.6** (Primary) — cliproxy 프록시 경유
2. **Gemini 2.5 Flash** (Fallback 1) — Google AI API 직접 호출
3. **Groq llama-3.3-70b** (Fallback 2) — Groq API 직접 호출
Primary가 실패하거나 빈 응답이면 자동으로 다음 순서로 전환됩니다.
### 설정 파일
`config/engine.json`
```json
"writing": {
"provider": "claude",
"fallback_chain": ["gemini", "groq"],
"options": {
"claude": {
"api_key_env": "ANTHROPIC_API_KEY",
"base_url": "http://192.168.0.17:8317/api/provider/claude",
"model": "claude-opus-4-6",
"max_tokens": 4096,
"temperature": 0.7
},
"gemini": {
"api_key_env": "GEMINI_API_KEY",
"model": "gemini-2.5-flash",
"max_tokens": 4096,
"temperature": 0.7
},
"groq": {
"api_key_env": "GROQ_API_KEY",
"model": "llama-3.3-70b-versatile",
"max_tokens": 4096,
"temperature": 0.7
}
}
}
```
### 환경변수 (.env)
| 변수명 | 설명 |
|--------|------|
| `ANTHROPIC_API_KEY` | cliproxy API 키 (`Jinie4eva!`) |
| `ANTHROPIC_BASE_URL` | cliproxy 내부 URL (`http://192.168.0.17:8317/api/provider/claude`) |
| `GEMINI_API_KEY` | Google AI API 키 |
| `GROQ_API_KEY` | Groq API 키 |
---
## cliproxy 설정
- **서비스**: NAS에서 Docker 컨테이너로 실행 (`CLIProxyAPI`)
- **내부 포트**: `8317`
- **외부 도메인**: `cliproxy.gru.farm`
- **내부 접근 URL**: `http://192.168.0.17:8317`
> **주의**: NAS 컨테이너에서 `cliproxy.gru.farm`(외부 도메인)으로 호출하면 헤어핀 NAT 문제로 401 오류가 발생합니다. 반드시 내부 IP(`192.168.0.17:8317`)로 연결해야 합니다.
---
## 텔레그램 대화 기능
텔레그램에서 봇에게 말을 걸면 Claude Opus 4.6으로 응답합니다.
Fallback 없이 Claude 단독 사용 (글 작성과 별개).
- **모델**: `claude-opus-4-6`
- **호출 경로**: `ANTHROPIC_BASE_URL` → cliproxy → Claude
- **구현 위치**: `bots/scheduler.py``handle_message()`
---
## 구현 위치
| 기능 | 파일 |
|------|------|
| Writer 클래스 (Claude/Gemini/Groq/Fallback) | `bots/engine_loader.py` |
| 글 작성 호출 | `bots/scheduler.py``_call_openclaw()` |
| 텔레그램 대화 | `bots/scheduler.py``handle_message()` |

View File

@@ -39,8 +39,13 @@ def main():
creds.refresh(Request())
print("[OK] 기존 토큰 갱신 완료")
else:
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_PATH, SCOPES)
creds = flow.run_local_server(port=0)
# credentials.json이 "web" 타입이면 "installed"로 변환
with open(CREDENTIALS_PATH) as f:
client_config = json.load(f)
if 'web' in client_config and 'installed' not in client_config:
client_config['installed'] = client_config.pop('web')
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
creds = flow.run_local_server(port=8080, prompt='consent')
print("[OK] 새 토큰 발급 완료")
with open(TOKEN_PATH, 'w') as token_file:

View File

@@ -0,0 +1,47 @@
"""
YouTube 전용 OAuth2 토큰 발급 스크립트
credentials_youtube.json 사용 (Blogger와 별도 OAuth 클라이언트)
"""
import json
import os
import sys
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
SCOPES = [
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube',
]
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CREDENTIALS_PATH = os.path.join(BASE_DIR, 'credentials_youtube.json')
def main():
if not os.path.exists(CREDENTIALS_PATH):
print(f"[ERROR] credentials_youtube.json 없음: {CREDENTIALS_PATH}")
sys.exit(1)
with open(CREDENTIALS_PATH) as f:
client_config = json.load(f)
if 'web' in client_config and 'installed' not in client_config:
client_config['installed'] = client_config.pop('web')
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
creds = flow.run_local_server(port=8080, prompt='consent')
print("[OK] 새 토큰 발급 완료")
token_data = json.loads(creds.to_json())
refresh_token = token_data.get('refresh_token', '')
print("\n" + "=" * 50)
print("YouTube 토큰 발급 성공!")
print("=" * 50)
print(f"\nYOUTUBE_REFRESH_TOKEN:\n{refresh_token}")
print(f"\n이 값을 .env 파일의 YOUTUBE_REFRESH_TOKEN 에 붙여넣으세요.")
if __name__ == '__main__':
main()

View File

@@ -1,21 +1,29 @@
You are a YouTube Shorts script writer for a Korean tech blog.
Given the blog post below, extract a 1520 second Shorts script.
Given the blog post below, extract a 3545 second Shorts script.
RULES:
- hook: 1 provocative question in Korean, ≤8 words. Must trigger curiosity.
- body: 23 short declarative claims, each ≤15 words.
- closer: 1 punchline or call-to-action, ≤10 words.
- keywords: 35 English terms for stock video search.
- hook: 1 provocative question or bold statement in Korean, ≤12 words. Must trigger curiosity and stop scrolling.
- body: 57 short declarative points, each ≤20 words. Build a narrative arc: problem → evidence → insight → surprise.
- closer: 1 strong call-to-action or memorable punchline, ≤15 words.
- keywords: 57 English terms for stock video search (diverse, specific scenes).
- mood: one of [dramatic, upbeat, mysterious, calm].
- Total spoken word count: 4060 words (Korean).
- Total spoken word count: 100140 words (Korean).
- originality_check: 1-sentence statement of what makes this script unique vs. generic content.
STRUCTURE GUIDE:
1. Hook — 호기심 유발 질문 (3초)
2. Context — 왜 이게 중요한지 배경 (5초)
3. Point 13 — 핵심 정보/주장 (15초)
4. Twist/Surprise — 반전 또는 의외의 사실 (5초)
5. Insight — 정리 또는 시사점 (5초)
6. Closer — CTA 또는 임팩트 있는 한마디 (3초)
OUTPUT FORMAT (JSON only, no markdown):
{
"hook": "...",
"body": ["...", "...", "..."],
"body": ["...", "...", "...", "...", "...", "..."],
"closer": "...",
"keywords": ["...", "...", "..."],
"keywords": ["...", "...", "...", "...", "..."],
"mood": "...",
"originality_check": "..."
}