Files
blog-writer/bots/linker_bot.py
sinmb79 9b44a07a44 feat: v3.2 — YouTube Shorts 봇 + 수동 어시스트 + 보안 개선
주요 추가 기능:
- bots/shorts/ 서브모듈 7개: tts_engine, script_extractor, asset_resolver,
  stock_fetcher, caption_renderer, video_assembler, youtube_uploader
- bots/shorts_bot.py: 6단계 Shorts 파이프라인 오케스트레이터
  (auto/semi_auto 두 가지 생산 모드, CLI 지원)
- bots/writer_bot.py: 독립 실행형 AI 글쓰기 봇 (대시보드 연동)
- bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인
- config/shorts_config.json: Shorts 전체 설정
- templates/shorts/extract_prompt.txt: LLM 스크립트 추출 프롬프트
- scheduler.py에 shorts 잡(10:35/16:00) + /shorts Telegram 명령 추가

보안 개선:
- .env 파일 외부 경로 참조로 변경 (load_dotenv dotenv_path, 24개 파일)
- .gitignore에 민감 파일/내부 문서/런타임 데이터 항목 추가

문서:
- README.md 전면 재작성 (상세 한글 설명, 설치/설정/사용법 포함)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:51:02 +09:00

223 lines
7.8 KiB
Python

"""
링크봇 (linker_bot.py)
역할: 글 본문에 쿠팡 파트너스 링크와 어필리에이트 링크 자동 삽입
"""
import hashlib
import hmac
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'linker.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
COUPANG_ACCESS_KEY = os.getenv('COUPANG_ACCESS_KEY', '')
COUPANG_SECRET_KEY = os.getenv('COUPANG_SECRET_KEY', '')
COUPANG_API_BASE = 'https://api-gateway.coupang.com'
def load_config(filename: str) -> dict:
with open(CONFIG_DIR / filename, 'r', encoding='utf-8') as f:
return json.load(f)
# ─── 쿠팡 파트너스 API ────────────────────────────────
def _generate_coupang_hmac(method: str, url: str, query: str) -> dict:
"""쿠팡 HMAC 서명 생성"""
datetime_str = datetime.now(timezone.utc).strftime('%y%m%dT%H%M%SZ')
path = url.split(COUPANG_API_BASE)[-1].split('?')[0]
message = datetime_str + method + path + query
signature = hmac.new(
COUPANG_SECRET_KEY.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
).hexdigest()
return {
'Authorization': f'CEA algorithm=HmacSHA256, access-key={COUPANG_ACCESS_KEY}, '
f'signed-date={datetime_str}, signature={signature}',
'Content-Type': 'application/json;charset=UTF-8',
}
def search_coupang_products(keyword: str, limit: int = 3) -> list[dict]:
"""쿠팡 파트너스 API로 상품 검색"""
if not COUPANG_ACCESS_KEY or not COUPANG_SECRET_KEY:
logger.warning("쿠팡 API 키 없음 — 링크 삽입 건너뜀")
return []
path = '/v2/providers/affiliate_api/apis/openapi/products/search'
params = {
'keyword': keyword,
'limit': limit,
'subId': 'blog-writer',
}
query_string = urlencode(params)
url = f'{COUPANG_API_BASE}{path}?{query_string}'
try:
headers = _generate_coupang_hmac('GET', url, query_string)
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
products = data.get('data', {}).get('productData', [])
return [
{
'name': p.get('productName', keyword),
'price': p.get('productPrice', 0),
'url': p.get('productUrl', ''),
'image': p.get('productImage', ''),
}
for p in products[:limit]
]
except Exception as e:
logger.warning(f"쿠팡 API 오류 ({keyword}): {e}")
return []
def build_coupang_link_html(product: dict) -> str:
"""쿠팡 상품 링크 HTML 생성"""
name = product.get('name', '')
url = product.get('url', '')
price = product.get('price', 0)
price_str = f"{int(price):,}" if price else ''
return (
f'<p class="coupang-link">'
f'🛒 <a href="{url}" target="_blank" rel="nofollow">{name}</a>'
f'{"" + price_str if price_str else ""}'
f'</p>\n'
)
# ─── 본문 링크 삽입 ──────────────────────────────────
def insert_links_into_html(html_content: str, coupang_keywords: list[str],
fixed_links: list[dict]) -> str:
"""HTML 본문에 쿠팡 링크와 고정 링크 삽입"""
soup = BeautifulSoup(html_content, 'lxml')
# 고정 링크 (키워드 텍스트가 본문에 있으면 첫 번째 등장 위치에 링크)
for fixed in fixed_links:
kw = fixed.get('keyword', '')
link_url = fixed.get('url', '')
label = fixed.get('label', kw)
if not kw or not link_url:
continue
for p in soup.find_all(['p', 'li']):
text = p.get_text()
if kw in text:
# 이미 링크가 있으면 건너뜀
if p.find('a', string=re.compile(re.escape(kw))):
break
new_html = p.decode_contents().replace(
kw,
f'<a href="{link_url}" target="_blank">{kw}</a>',
1
)
p.clear()
p.append(BeautifulSoup(new_html, 'lxml'))
break
# 쿠팡 링크: 결론/추천 섹션 앞에 상품 박스 삽입
if coupang_keywords and (COUPANG_ACCESS_KEY and COUPANG_SECRET_KEY):
coupang_block_parts = []
for kw in coupang_keywords[:3]: # 최대 3개 키워드
products = search_coupang_products(kw, limit=2)
for product in products:
coupang_block_parts.append(build_coupang_link_html(product))
if coupang_block_parts:
coupang_block_html = (
'<div class="coupang-products">\n'
'<p><strong>관련 상품 추천</strong></p>\n'
+ ''.join(coupang_block_parts) +
'</div>\n'
)
# 결론 H2 앞에 삽입
for h2 in soup.find_all('h2'):
if any(kw in h2.get_text() for kw in ['결론', '마무리', '정리', '요약']):
block = BeautifulSoup(coupang_block_html, 'lxml')
h2.insert_before(block)
break
else:
# 결론 섹션 없으면 본문 끝에 추가
body_tag = soup.find('body') or soup
block = BeautifulSoup(coupang_block_html, 'lxml')
body_tag.append(block)
return str(soup)
def add_disclaimer(html_content: str, disclaimer_text: str) -> str:
"""쿠팡 필수 면책 문구 추가 (이미 있으면 건너뜀)"""
if disclaimer_text in html_content:
return html_content
disclaimer_html = (
f'\n<hr/>\n'
f'<p class="affiliate-disclaimer"><small>⚠️ {disclaimer_text}</small></p>\n'
)
return html_content + disclaimer_html
# ─── 메인 함수 ───────────────────────────────────────
def process(article: dict, html_content: str) -> str:
"""
링크봇 메인: HTML 본문에 쿠팡/어필리에이트 링크 삽입 후 반환
"""
logger.info(f"링크 삽입 시작: {article.get('title', '')}")
affiliate_cfg = load_config('affiliate_links.json')
coupang_keywords = article.get('coupang_keywords', [])
fixed_links = affiliate_cfg.get('fixed_links', [])
disclaimer_text = affiliate_cfg.get('disclaimer_text', '')
# 링크 삽입
html_content = insert_links_into_html(html_content, coupang_keywords, fixed_links)
# 쿠팡 키워드가 있으면 면책 문구 추가
if coupang_keywords and disclaimer_text:
html_content = add_disclaimer(html_content, disclaimer_text)
logger.info("링크 삽입 완료")
return html_content
if __name__ == '__main__':
sample_html = '''
<h2>ChatGPT 소개</h2>
<p>ChatGPT Plus를 사용하면 더 빠른 응답을 받을 수 있습니다.</p>
<h2>키보드 추천</h2>
<p>좋은 키보드는 생산성을 높입니다.</p>
<h2>결론</h2>
<p>AI 도구를 잘 활용하세요.</p>
'''
sample_article = {
'title': '테스트 글',
'coupang_keywords': ['키보드', '마우스'],
}
result = process(sample_article, sample_html)
print(result[:500])