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>
This commit is contained in:
JOUNGWOOK KWON
2026-03-30 18:19:13 +09:00
parent d9f932b333
commit 178caade3f

View File

@@ -260,8 +260,53 @@ def _fetch_og_image(url: str) -> str:
return ''
def _search_real_article_image(sources: list, topic: str = '') -> str:
"""Google News 소스 → DuckDuckGo 검색으로 실제 기사 URL 찾기 → og:image 크롤링"""
from urllib.parse import quote as _quote, urlparse, parse_qs, unquote
from bs4 import BeautifulSoup as _BS
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 = _BS(resp.text, 'lxml')
# DuckDuckGo는 redirect URL 사용: //duckduckgo.com/l/?uddg=실제URL
for a_tag in soup.select('a.result__a')[:3]:
href = a_tag.get('href', '')
# uddg 파라미터에서 실제 URL 추출
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
# 실제 기사 URL에서 og:image 크롤링
img = _fetch_og_image(real_url)
if img:
logger.info(f"원문 기사 이미지 발견: {real_url[:50]}{img[:60]}")
return img
except Exception as e:
logger.debug(f"DuckDuckGo 검색 실패: {e}")
return ''
def fetch_featured_image(article: dict) -> str:
"""대표 이미지: RSS 이미지 → og:image 크롤링 → Pexels 순으로 시도"""
"""대표 이미지: RSS 이미지 → 원문 기사 → og:image → Wikipedia 순으로 시도"""
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 제외)
source_image = article.get('source_image', '')
if source_image and source_image.startswith('http') and not _is_platform_logo(source_image):
@@ -278,7 +323,14 @@ def fetch_featured_image(article: dict) -> str:
if og_image:
return og_image
# 3) Pexels API (키가 있을 때)
# 3) Google News 소스 → DuckDuckGo로 실제 기사 검색 → og:image
sources = article.get('sources', [])
if sources:
real_image = _search_real_article_image(sources, article.get('title', ''))
if real_image:
return real_image
# 4) Pexels API (키가 있을 때)
pexels_key = os.getenv('PEXELS_API_KEY', '')
if pexels_key:
tags = article.get('tags', [])
@@ -299,17 +351,16 @@ def fetch_featured_image(article: dict) -> str:
except Exception as e:
logger.warning(f"Pexels 이미지 검색 실패: {e}")
# 4) Wikipedia 썸네일 (무료, API 키 불필요) — 태그 전체 시도
# 5) Wikipedia 썸네일 (무료, API 키 불필요)
tags = article.get('tags', [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
# 태그만 사용 (제목은 너무 길어 Wikipedia에서 매칭 안됨)
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:
# 한국어 Wikipedia
for lang in ['ko', 'en']:
try:
wiki_url = f'https://ko.wikipedia.org/api/rest_v1/page/summary/{_quote(kw)}'
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:
@@ -317,21 +368,7 @@ def fetch_featured_image(article: dict) -> str:
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 이미지 사용: {kw}{thumb[:60]}")
return thumb
except Exception:
pass
# 영문 Wikipedia
try:
wiki_url = f'https://en.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(EN) 이미지 사용: {kw}{thumb[:60]}")
logger.info(f"Wikipedia({lang}) 이미지 사용: {kw}{thumb[:60]}")
return thumb
except Exception:
pass
@@ -351,13 +388,15 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
if not has_image:
image_url = fetch_featured_image(article)
if image_url:
title = article.get('title', '')
html_parts.append(
f'<div class="featured-image" style="margin-bottom:1.5em;">'
title = article.get('title', '').replace('"', '&quot;')
# Blogger 호환: div 래핑 없이 직접 img 삽입 (본문 첫 줄에 배치)
img_tag = (
f'<img src="{image_url}" alt="{title}" '
f'style="width:100%;max-height:400px;object-fit:cover;border-radius:8px;" />'
f'</div>'
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
# body_html 맨 앞에 이미지 삽입 (Blogger가 div를 제거하는 문제 방지)
body_html = img_tag + '\n' + body_html
html_parts.append(json_ld)
# 목차: h2가 3개 이상인 긴 글에서만 표시