diff --git a/app/workers/news_collector.py b/app/workers/news_collector.py index dc8ad90..5b4c8ad 100644 --- a/app/workers/news_collector.py +++ b/app/workers/news_collector.py @@ -4,7 +4,7 @@ import hashlib import re from datetime import datetime, timezone from html import unescape -from urllib.parse import urlparse, urlunparse +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import feedparser import httpx @@ -52,10 +52,24 @@ def _clean_html(text: str) -> str: return text.strip()[:1000] +# tracking 파라미터 판별 — prefix(utm_/at_=BBC/ns_=BBC/mc_=mailchimp) + 단독 키 +_TRACKING_PREFIXES = ("utm_", "at_", "ns_", "mc_") +_TRACKING_PARAMS = {"fbclid", "gclid", "igshid", "ref", "smid", "partner", "cmp", "ocid", "ftag"} + + def _normalize_url(url: str) -> str: - """URL 정규화 (tracking params 제거)""" + """URL 정규화 — tracking 파라미터만 제거, 콘텐츠 식별 파라미터는 보존. + + query 전체 제거 금지: hada.io/topic?id= · aitimes articleView.html?idxno= · + HN item?id= 등 query-식별 사이트에서 별개 기사가 같은 URL 로 붕괴된다. + 저장(edit_url)·조회 양쪽이 이 함수를 공유해야 dedup 이 성립. + """ parsed = urlparse(url) - return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", "")) + kept = [ + (k, v) for k, v in parse_qsl(parsed.query, keep_blank_values=True) + if not (k.lower().startswith(_TRACKING_PREFIXES) or k.lower() in _TRACKING_PARAMS) + ] + return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", urlencode(kept), "")) def _article_hash(title: str, published: str, source_name: str) -> str: @@ -180,17 +194,19 @@ async def _fetch_rss(session, source: NewsSource) -> int: published = entry.get("published_parsed") or entry.get("updated_parsed") pub_dt = datetime(*published[:6], tzinfo=timezone.utc) if published else datetime.now(timezone.utc) - # 중복 체크 + # 중복 체크 — 레거시 행은 raw URL 로 저장돼 있어 normalized/raw 양쪽 매칭. + # 교차 게시(같은 기사가 두 피드에 존재)로 2행 이상 매칭될 수 있어 first() 사용 + # (scalar_one_or_none 은 MultipleResultsFound raise — 2026-06 BBC 수집 중단 원인). article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name) normalized_url = _normalize_url(link) existing = await session.execute( select(Document).where( (Document.file_hash == article_id) | - (Document.edit_url == normalized_url) - ) + (Document.edit_url.in_([normalized_url, link])) + ).limit(1) ) - if existing.scalar_one_or_none(): + if existing.scalars().first(): continue category = _normalize_category(source.category or "") @@ -213,7 +229,8 @@ async def _fetch_rss(session, source: NewsSource) -> int: md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상", source_channel="news", data_origin="external", - edit_url=link, + # 조회와 동일하게 정규화해 저장 — raw(tracking param 포함) 저장 시 URL dedup 무력화 + edit_url=normalized_url, review_status="approved", ai_domain="News", ai_sub_group=source_short, @@ -282,13 +299,14 @@ async def _fetch_api(session, source: NewsSource) -> int: article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name) normalized_url = _normalize_url(link) + # RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first) existing = await session.execute( select(Document).where( (Document.file_hash == article_id) | - (Document.edit_url == normalized_url) - ) + (Document.edit_url.in_([normalized_url, link])) + ).limit(1) ) - if existing.scalar_one_or_none(): + if existing.scalars().first(): continue category = _normalize_category(article.get("section", source.category or "")) @@ -311,7 +329,7 @@ async def _fetch_api(session, source: NewsSource) -> int: md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상", source_channel="news", data_origin="external", - edit_url=link, + edit_url=normalized_url, review_status="approved", ai_domain="News", ai_sub_group=source_short,