From 15dfc39f0f90d0f086325cf14bbe6cbdbf71e449 Mon Sep 17 00:00:00 2001 From: JOUNGWOOK KWON Date: Wed, 1 Apr 2026 18:28:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=94=EB=A0=88=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=A8=EB=B6=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- bots/engine_loader.py | 6 +- bots/publisher_bot.py | 174 ++++++++++++++++++++++++++++++++++++------ bots/scheduler.py | 119 +++++++++++++++++++++++++++-- 3 files changed, 268 insertions(+), 31 deletions(-) diff --git a/bots/engine_loader.py b/bots/engine_loader.py index eba41ea..1a41a26 100644 --- a/bots/engine_loader.py +++ b/bots/engine_loader.py @@ -81,7 +81,11 @@ class ClaudeWriter(BaseWriter): return '' try: import anthropic - client_kwargs = {'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) diff --git a/bots/publisher_bot.py b/bots/publisher_bot.py index e2cbe2f..182933e 100644 --- a/bots/publisher_bot.py +++ b/bots/publisher_bot.py @@ -262,24 +262,38 @@ def _is_relevant_image(image_url: str, article: dict) -> bool: return True -def _fetch_og_image(url: str) -> str: +def _fetch_og_image(url: str, skip_irrelevant_section: bool = True) -> str: """원본 기사 URL에서 og:image 메타태그 크롤링 (본문 이미지 검증 포함)""" if not url or not url.startswith('http'): return '' - # Google 뉴스 리다이렉트인 경우 실제 기사 URL 추출 시도 (head는 리다이렉트 안됨 → get 사용) + # 문화/엔터/스포츠 섹션 기사는 이미지 추출 건너뜀 + 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 - except Exception: - pass + 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 (compatible; BlogBot/1.0)', + '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') @@ -290,7 +304,7 @@ def _fetch_og_image(url: str) -> str: if src.startswith('http') and not _is_platform_logo(src): body_images.append(src) - # og:image가 본문 이미지와 동일 도메인이면 신뢰, 아니면 사이트 기본 이미지 가능성 + # og:image 추출 og_url = '' og = soup.find('meta', property='og:image') if og and og.get('content', '').startswith('http'): @@ -303,23 +317,84 @@ def _fetch_og_image(url: str) -> str: og_url = tw['content'] if og_url and body_images: - # og:image 도메인이 본문 이미지 도메인과 일치하면 신뢰 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일 가능성 → 본문 이미지 우선 사용 + # 도메인 불일치 → 사이트 기본 og:image일 가능성 → 본문 이미지 우선 logger.info(f"og:image 도메인({og_domain}) ≠ 본문 이미지 도메인 → 본문 이미지 사용") return body_images[0] - elif og_url and not body_images: - # 본문에 이미지가 없으면 og:image를 일단 사용 (검증 불가) + 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}): {e}") + logger.warning(f"og:image 크롤링 실패 ({url[:60]}): {e}") + return '' + + +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 '' @@ -327,6 +402,7 @@ def _fetch_og_image(url: str) -> str: 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): @@ -359,7 +435,14 @@ def fetch_featured_image(article: dict) -> str: if og_image and _is_relevant_image(og_image, article): return og_image - # 3) Wikipedia 썸네일 (무료, API 키 불필요) + # 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(',')] @@ -404,17 +487,62 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str: html_parts = [] if not has_image: - image_url = fetch_featured_image(article) - if image_url: - title = article.get('title', '').replace('"', '"') - # Blogger 호환: div 래핑 없이 직접 img 삽입 (본문 첫 줄에 배치) - img_tag = ( - f'{title}' - ) - # body_html 맨 앞에 이미지 삽입 (Blogger가 div를 제거하는 문제 방지) - body_html = img_tag + '\n' + body_html + title = article.get('title', '').replace('"', '"') + 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'{title}' + ) + 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 = len(boundary_indices) // (n_imgs + 1) + insert_positions = [spacing * (k + 1) for k in range(n_imgs)] + for img_idx, pos in enumerate(reversed(insert_positions)): + bi = boundary_indices[min(pos, len(boundary_indices) - 1)] + blocks.insert(bi, '\n' + img_tags[n_imgs - 1 - img_idx] + '\n') + body_html = ''.join(blocks) + else: + body_html = '\n'.join(img_tags) + '\n' + body_html + logger.info(f"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치") + else: + image_url = fetch_featured_image(article) + if image_url: + img_tag = ( + f'{title}' + ) + body_html = img_tag + '\n' + body_html html_parts.append(json_ld) # 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시 diff --git a/bots/scheduler.py b/bots/scheduler.py index 4010f39..4687013 100644 --- a/bots/scheduler.py +++ b/bots/scheduler.py @@ -92,6 +92,8 @@ IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower() # request 모드에서 이미지 대기 시 사용하는 상태 변수 # {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억 _awaiting_image: dict[int, str] = {} +# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename} +_awaiting_article_image: dict[int, str] = {} _publish_enabled = True @@ -867,13 +869,18 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE): title = article.get('title', '')[:50] corner = article.get('corner', '') body_preview = article.get('body', '')[:200].replace('<', '<').replace('>', '>') - # 인라인 버튼으로 승인/거부 - keyboard = InlineKeyboardMarkup([ + # 인라인 버튼으로 승인/거부 (+idea 글이면 이미지 첨부) + btn_rows = [ [ InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"), InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_name}"), ] - ]) + ] + if article.get('source') in ('idea', 'manual'): + img_count = len(article.get('user_images', [])) + img_label = f"📷 이미지 첨부 ({img_count}/3)" if img_count else "📷 이미지 첨부" + btn_rows.append([InlineKeyboardButton(img_label, callback_data=f"attachimg:{pending_name}")]) + keyboard = InlineKeyboardMarkup(btn_rows) await update.message.reply_text( f"📝 [수동 검토 필요]\n\n" f"{title}\n" @@ -1246,7 +1253,7 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE): corner = item.get('corner', '') reason = item.get('pending_reason', '') body_preview = item.get('body', '')[:150].replace('<', '<').replace('>', '>') - keyboard = InlineKeyboardMarkup([ + pending_btn_rows = [ [ InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{filename}"), InlineKeyboardButton("🗑 거부", callback_data=f"reject:{filename}"), @@ -1254,7 +1261,12 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE): [ InlineKeyboardButton("🏷 카테고리 변경", callback_data=f"setcorner:{filename}"), ] - ]) + ] + if item.get('source') in ('idea', 'manual'): + img_count = len(item.get('user_images', [])) + img_label = f"📷 이미지 첨부 ({img_count}/3)" if img_count else "📷 이미지 첨부" + pending_btn_rows.append([InlineKeyboardButton(img_label, callback_data=f"attachimg:{filename}")]) + keyboard = InlineKeyboardMarkup(pending_btn_rows) await update.message.reply_text( f"🔍 [{i}/{len(pending)}] 수동 검토 대기\n\n" f"{title}\n" @@ -1377,6 +1389,21 @@ async def callback_approve_reject(update: Update, context: ContextTypes.DEFAULT_ # 카테고리 선택 버튼 표시 buttons = [[InlineKeyboardButton(c, callback_data=f"docorner:{filename}:{c}")] for c in VALID_CORNERS] await query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons)) + elif action == 'attachimg': + # /idea, /topic 글 대표 이미지 첨부 대기 + article = json.loads(filepath.read_text(encoding='utf-8')) + img_count = len(article.get('user_images', [])) + if img_count >= 3: + await query.answer("이미지는 최대 3장까지입니다.", show_alert=True) + return + chat_id = query.message.chat_id + _awaiting_article_image[chat_id] = filename + remaining = 3 - img_count + await query.edit_message_text( + f"📷 대표 이미지로 사용할 사진을 보내주세요. ({img_count}/3, {remaining}장 추가 가능)\n\n" + f"사진을 연속으로 보내면 차례대로 저장됩니다.\n" + f"취소: /cancelimg" + ) elif action == 'docorner': # filename:corner 형태로 파싱 parts = filename.split(':', 1) @@ -1532,16 +1559,93 @@ async def cmd_imgcancel(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("현재 대기 중인 이미지 요청이 없습니다.") +async def cmd_cancelimg(update: Update, context: ContextTypes.DEFAULT_TYPE): + """글 대표 이미지 첨부 대기 취소""" + chat_id = update.message.chat_id + if chat_id in _awaiting_article_image: + _awaiting_article_image.pop(chat_id) + await update.message.reply_text("📷 이미지 첨부가 취소되었습니다.") + else: + await update.message.reply_text("대기 중인 이미지 첨부가 없습니다.") + + # ─── 이미지/파일 수신 핸들러 ───────────────────────── async def _receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE, file_getter, caption: str): """공통 이미지 수신 처리 (photo / document)""" sys.path.insert(0, str(BASE_DIR / 'bots')) - import image_bot chat_id = update.message.chat_id + # ── /idea, /topic 글 대표 이미지 첨부 처리 (최우선, 최대 3장) ── + pending_filename = _awaiting_article_image.get(chat_id) + if pending_filename: + pending_dir = DATA_DIR / 'pending_review' + pending_filepath = pending_dir / pending_filename + if not pending_filepath.exists(): + _awaiting_article_image.pop(chat_id, None) + await update.message.reply_text("⚠️ 해당 대기 글을 찾을 수 없습니다.") + return + article = json.loads(pending_filepath.read_text(encoding='utf-8')) + user_images = article.get('user_images', []) + if len(user_images) >= 3: + _awaiting_article_image.pop(chat_id, None) + await update.message.reply_text("⚠️ 이미지는 최대 3장까지 첨부할 수 있습니다.") + return + try: + tg_file = await file_getter() + file_bytes = bytes(await tg_file.download_as_bytearray()) + except Exception as e: + await update.message.reply_text(f"❌ 파일 다운로드 실패: {e}") + return + images_dir = DATA_DIR / 'images' + images_dir.mkdir(exist_ok=True) + safe_name = pending_filename.replace('.json', '') + img_num = len(user_images) + 1 + img_filename = f"{safe_name}_{img_num}.jpg" + img_path = images_dir / img_filename + img_path.write_bytes(file_bytes) + # pending JSON에 user_images 리스트 추가 + user_images.append(str(img_path)) + article['user_images'] = user_images + # 하위호환: 첫 번째 이미지를 user_image에도 저장 + article['user_image'] = user_images[0] + pending_filepath.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8') + img_count = len(user_images) + if img_count < 3: + # 아직 추가 가능 → 대기 상태 유지 + img_label = f"📷 이미지 추가 ({img_count}/3)" + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_filename}"), + InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_filename}"), + ], + [InlineKeyboardButton(img_label, callback_data=f"attachimg:{pending_filename}")] + ]) + await update.message.reply_text( + f"✅ 이미지 {img_count}장 저장! (최대 3장)\n\n" + f"추가 이미지를 보내거나 승인 버튼을 눌러주세요.", + reply_markup=keyboard, + ) + else: + # 3장 완료 → 대기 해제 + _awaiting_article_image.pop(chat_id, None) + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_filename}"), + InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_filename}"), + ] + ]) + await update.message.reply_text( + f"✅ 이미지 3장 모두 저장!\n\n승인 버튼을 눌러주세요.", + reply_markup=keyboard, + ) + logger.info(f"대표 이미지 저장 ({img_count}/3): {pending_filename} → {img_path}") + return + + import image_bot + # 프롬프트 ID 결정: 대기 상태 > 캡션 파싱 > 없음 prompt_id = _awaiting_image.get(chat_id) if not prompt_id and caption: @@ -1891,7 +1995,7 @@ async def main(): app.add_handler(CommandHandler('approve', cmd_approve)) app.add_handler(CommandHandler('reject', cmd_reject)) app.add_handler(CommandHandler('pending', cmd_pending)) - app.add_handler(CallbackQueryHandler(callback_approve_reject, pattern=r'^(approve|reject|setcorner|docorner):')) + app.add_handler(CallbackQueryHandler(callback_approve_reject, pattern=r'^(approve|reject|setcorner|docorner|attachimg):')) app.add_handler(CommandHandler('setcorner', cmd_setcorner)) app.add_handler(CommandHandler('report', cmd_report)) app.add_handler(CommandHandler('idea', cmd_idea)) @@ -1904,6 +2008,7 @@ async def main(): app.add_handler(CommandHandler('imgpick', cmd_imgpick)) app.add_handler(CommandHandler('imgbatch', cmd_imgbatch)) app.add_handler(CommandHandler('imgcancel', cmd_imgcancel)) + app.add_handler(CommandHandler('cancelimg', cmd_cancelimg)) # 소설 파이프라인 app.add_handler(CommandHandler('novel_list', cmd_novel_list))