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:
@@ -260,8 +260,53 @@ def _fetch_og_image(url: str) -> str:
|
|||||||
return ''
|
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:
|
def fetch_featured_image(article: dict) -> str:
|
||||||
"""대표 이미지: RSS 이미지 → og:image 크롤링 → Pexels 순으로 시도"""
|
"""대표 이미지: RSS 이미지 → 원문 기사 → og:image → Wikipedia 순으로 시도"""
|
||||||
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 제외)
|
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 제외)
|
||||||
source_image = article.get('source_image', '')
|
source_image = article.get('source_image', '')
|
||||||
if source_image and source_image.startswith('http') and not _is_platform_logo(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:
|
if og_image:
|
||||||
return 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', '')
|
pexels_key = os.getenv('PEXELS_API_KEY', '')
|
||||||
if pexels_key:
|
if pexels_key:
|
||||||
tags = article.get('tags', [])
|
tags = article.get('tags', [])
|
||||||
@@ -299,42 +351,27 @@ def fetch_featured_image(article: dict) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Pexels 이미지 검색 실패: {e}")
|
logger.warning(f"Pexels 이미지 검색 실패: {e}")
|
||||||
|
|
||||||
# 4) Wikipedia 썸네일 (무료, API 키 불필요) — 태그 전체 시도
|
# 5) Wikipedia 썸네일 (무료, API 키 불필요)
|
||||||
tags = article.get('tags', [])
|
tags = article.get('tags', [])
|
||||||
if isinstance(tags, str):
|
if isinstance(tags, str):
|
||||||
tags = [t.strip() for t in tags.split(',')]
|
tags = [t.strip() for t in tags.split(',')]
|
||||||
# 태그만 사용 (제목은 너무 길어 Wikipedia에서 매칭 안됨)
|
|
||||||
search_keywords = [t for t in tags if t and len(t) <= 15][:8]
|
search_keywords = [t for t in tags if t and len(t) <= 15][:8]
|
||||||
from urllib.parse import quote as _quote
|
from urllib.parse import quote as _quote
|
||||||
for kw in search_keywords:
|
for kw in search_keywords:
|
||||||
# 한국어 Wikipedia
|
for lang in ['ko', 'en']:
|
||||||
try:
|
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,
|
resp = requests.get(wiki_url, timeout=6,
|
||||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
thumb = data.get('thumbnail', {}).get('source', '')
|
thumb = data.get('thumbnail', {}).get('source', '')
|
||||||
if thumb and thumb.startswith('http') and not _is_platform_logo(thumb):
|
if thumb and thumb.startswith('http') and not _is_platform_logo(thumb):
|
||||||
thumb = re.sub(r'/\d+px-', '/800px-', thumb)
|
thumb = re.sub(r'/\d+px-', '/800px-', thumb)
|
||||||
logger.info(f"Wikipedia 이미지 사용: {kw} → {thumb[:60]}")
|
logger.info(f"Wikipedia({lang}) 이미지 사용: {kw} → {thumb[:60]}")
|
||||||
return thumb
|
return thumb
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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]}")
|
|
||||||
return thumb
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
@@ -351,13 +388,15 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
|
|||||||
if not has_image:
|
if not has_image:
|
||||||
image_url = fetch_featured_image(article)
|
image_url = fetch_featured_image(article)
|
||||||
if image_url:
|
if image_url:
|
||||||
title = article.get('title', '')
|
title = article.get('title', '').replace('"', '"')
|
||||||
html_parts.append(
|
# Blogger 호환: div 래핑 없이 직접 img 삽입 (본문 첫 줄에 배치)
|
||||||
f'<div class="featured-image" style="margin-bottom:1.5em;">'
|
img_tag = (
|
||||||
f'<img src="{image_url}" alt="{title}" '
|
f'<img src="{image_url}" alt="{title}" '
|
||||||
f'style="width:100%;max-height:400px;object-fit:cover;border-radius:8px;" />'
|
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||||
f'</div>'
|
f'margin-bottom:1.2em;" />'
|
||||||
)
|
)
|
||||||
|
# body_html 맨 앞에 이미지 삽입 (Blogger가 div를 제거하는 문제 방지)
|
||||||
|
body_html = img_tag + '\n' + body_html
|
||||||
|
|
||||||
html_parts.append(json_ld)
|
html_parts.append(json_ld)
|
||||||
# 목차: h2가 3개 이상인 긴 글에서만 표시
|
# 목차: h2가 3개 이상인 긴 글에서만 표시
|
||||||
|
|||||||
Reference in New Issue
Block a user