Skip to content

Commit 4bc159d

Browse files
committed
feat: dedupe and rank daily article candidates
1 parent 9829688 commit 4bc159d

3 files changed

Lines changed: 407 additions & 68 deletions

File tree

.github/scripts/collect_articles.py

Lines changed: 190 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,155 @@
66

77
import json
88
import os
9+
import re
910
import requests
1011
from bs4 import BeautifulSoup
1112
from datetime import datetime, timedelta
1213
import feedparser
1314
from dateutil import parser as date_parser
15+
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
1416

15-
def collect_geeknews():
17+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
18+
GITHUB_DIR = os.path.dirname(SCRIPT_DIR)
19+
CANDIDATES_DIR = os.path.join(GITHUB_DIR, 'candidates')
20+
PROCESSED_URLS_FILE = os.path.join(CANDIDATES_DIR, 'processed_urls.json')
21+
COLLECTED_ARTICLES_FILE = os.path.join(CANDIDATES_DIR, 'collected_articles.json')
22+
23+
TRACKING_QUERY_KEYS = {
24+
'fbclid', 'gclid', 'mc_cid', 'mc_eid', 'ref', 'ref_src', 'source'
25+
}
26+
27+
28+
def ensure_candidates_dir():
29+
os.makedirs(CANDIDATES_DIR, exist_ok=True)
30+
31+
32+
def normalize_url(url):
33+
"""URL 정규화 (중복 제거를 위한 canonical URL 생성)"""
34+
if not url:
35+
return ""
36+
37+
try:
38+
parsed = urlparse(url.strip())
39+
scheme = (parsed.scheme or 'https').lower()
40+
netloc = parsed.netloc.lower()
41+
path = parsed.path.rstrip('/') or '/'
42+
43+
query_pairs = []
44+
for key, value in parse_qsl(parsed.query, keep_blank_values=False):
45+
key_lower = key.lower()
46+
if key_lower.startswith('utm_') or key_lower in TRACKING_QUERY_KEYS:
47+
continue
48+
query_pairs.append((key, value))
49+
50+
query_pairs.sort(key=lambda x: (x[0], x[1]))
51+
query = urlencode(query_pairs, doseq=True)
52+
return urlunparse((scheme, netloc, path, '', query, ''))
53+
except Exception:
54+
return url.strip()
55+
56+
57+
def normalize_title(title):
58+
"""제목 정규화 (URL이 다르더라도 동일 아티클 감지)"""
59+
if not title:
60+
return ""
61+
lowered = title.lower().strip()
62+
cleaned = re.sub(r'[^\w가-힣]+', ' ', lowered)
63+
return re.sub(r'\s+', ' ', cleaned).strip()
64+
65+
66+
def safe_int(value):
67+
try:
68+
return int(value)
69+
except (TypeError, ValueError):
70+
return 0
71+
72+
73+
def parse_datetime(value):
74+
if not value:
75+
return None
76+
try:
77+
return date_parser.parse(value)
78+
except (TypeError, ValueError):
79+
return None
80+
81+
82+
def merge_articles(existing, incoming):
83+
"""중복 아티클 병합: 더 풍부한 메타데이터를 유지"""
84+
merged = dict(existing)
85+
86+
# URL은 canonical URL을 우선 사용
87+
existing_url = normalize_url(existing.get('url'))
88+
incoming_url = normalize_url(incoming.get('url'))
89+
if incoming_url and (not existing_url or len(incoming_url) > len(existing_url)):
90+
merged['url'] = incoming_url
91+
elif existing_url:
92+
merged['url'] = existing_url
93+
94+
# 제목/요약은 더 긴 정보를 유지
95+
if len(incoming.get('title', '')) > len(existing.get('title', '')):
96+
merged['title'] = incoming.get('title', '')
97+
if len(incoming.get('summary', '')) > len(existing.get('summary', '')):
98+
merged['summary'] = incoming.get('summary', '')
99+
100+
# 소셜 지표는 큰 값을 유지
101+
merged['upvotes'] = max(safe_int(existing.get('upvotes')), safe_int(incoming.get('upvotes')))
102+
merged['comments'] = max(safe_int(existing.get('comments')), safe_int(incoming.get('comments')))
103+
104+
# 발행일은 최신 값을 유지
105+
existing_dt = parse_datetime(existing.get('published_at'))
106+
incoming_dt = parse_datetime(incoming.get('published_at'))
107+
if existing_dt and incoming_dt:
108+
merged['published_at'] = max(existing_dt, incoming_dt).isoformat()
109+
elif incoming_dt:
110+
merged['published_at'] = incoming_dt.isoformat()
111+
elif existing_dt:
112+
merged['published_at'] = existing_dt.isoformat()
113+
else:
114+
merged['published_at'] = incoming.get('published_at', existing.get('published_at', datetime.now().isoformat()))
115+
116+
# 출처는 기존 값 유지 (평가 로직 호환)
117+
merged['source'] = existing.get('source') or incoming.get('source') or 'GeekNews'
118+
return merged
119+
120+
121+
def deduplicate_articles(articles):
122+
"""URL/제목 기반 중복 제거"""
123+
deduped = []
124+
url_index = {}
125+
title_index = {}
126+
127+
for article in articles:
128+
normalized_url = normalize_url(article.get('url', ''))
129+
normalized_title = normalize_title(article.get('title', ''))
130+
131+
article_copy = dict(article)
132+
if normalized_url:
133+
article_copy['url'] = normalized_url
134+
135+
existing_idx = None
136+
if normalized_url and normalized_url in url_index:
137+
existing_idx = url_index[normalized_url]
138+
elif normalized_title and normalized_title in title_index:
139+
existing_idx = title_index[normalized_title]
140+
141+
if existing_idx is None:
142+
deduped.append(article_copy)
143+
idx = len(deduped) - 1
144+
if normalized_url:
145+
url_index[normalized_url] = idx
146+
if normalized_title:
147+
title_index[normalized_title] = idx
148+
else:
149+
deduped[existing_idx] = merge_articles(deduped[existing_idx], article_copy)
150+
if normalized_url:
151+
url_index[normalized_url] = existing_idx
152+
if normalized_title:
153+
title_index[normalized_title] = existing_idx
154+
155+
return deduped
156+
157+
def collect_geeknews(processed_urls):
16158
"""GeekNews에서 최신 아티클 수집"""
17159
url = "https://news.hada.io"
18160
headers = {
@@ -36,7 +178,13 @@ def collect_geeknews():
36178
continue
37179

38180
title = title_elem.get_text(strip=True)
39-
link = title_elem['href']
181+
raw_link = title_elem.get('href', '').strip()
182+
if not raw_link:
183+
continue
184+
185+
link = normalize_url(urljoin(url, raw_link))
186+
if link in processed_urls:
187+
continue
40188

41189
# 추천수
42190
upvotes = 0
@@ -86,21 +234,22 @@ def collect_geeknews():
86234

87235
def load_processed_urls():
88236
"""이전에 처리된 URL 목록 로드"""
89-
url_file = os.path.join(os.path.dirname(__file__), '..', '..', 'candidates', 'processed_urls.json')
90-
if os.path.exists(url_file):
237+
ensure_candidates_dir()
238+
if os.path.exists(PROCESSED_URLS_FILE):
91239
try:
92-
with open(url_file, 'r', encoding='utf-8') as f:
93-
return set(json.load(f))
240+
with open(PROCESSED_URLS_FILE, 'r', encoding='utf-8') as f:
241+
return {normalize_url(url) for url in json.load(f) if normalize_url(url)}
94242
except Exception as e:
95243
print(f"Error loading processed URLs: {e}")
96244
return set()
97245

98246
def save_processed_urls(urls):
99247
"""처리된 URL 목록 저장"""
100-
url_file = os.path.join(os.path.dirname(__file__), '..', '..', 'candidates', 'processed_urls.json')
248+
ensure_candidates_dir()
101249
try:
102-
with open(url_file, 'w', encoding='utf-8') as f:
103-
json.dump(list(urls), f, ensure_ascii=False, indent=2)
250+
normalized_urls = sorted({normalize_url(url) for url in urls if normalize_url(url)})
251+
with open(PROCESSED_URLS_FILE, 'w', encoding='utf-8') as f:
252+
json.dump(normalized_urls, f, ensure_ascii=False, indent=2)
104253
except Exception as e:
105254
print(f"Error saving processed URLs: {e}")
106255

@@ -131,7 +280,7 @@ def scrape_geeknews_details(url):
131280

132281
# 댓글수 추출: "댓글 N개" 텍스트에서 추출
133282
comments = 0
134-
comments_elem = soup.find('a', text=re.compile(r'댓글\s+\d+개'))
283+
comments_elem = soup.find('a', string=re.compile(r'댓글\s+\d+개'))
135284
if comments_elem:
136285
comments_text = comments_elem.get_text(strip=True)
137286
comments_match = re.search(r'(\d+)개', comments_text)
@@ -147,7 +296,7 @@ def scrape_geeknews_details(url):
147296
print(f"Error scraping details from {url}: {e}")
148297
return 0, 0
149298

150-
def collect_rss_articles():
299+
def collect_rss_articles(processed_urls):
151300
"""GeekNews RSS에서 아티클 수집 - 개선된 버전"""
152301
geeknews_rss_url = "https://feeds.feedburner.com/geeknews-feed"
153302

@@ -158,21 +307,18 @@ def collect_rss_articles():
158307
print(f"Error parsing GeekNews RSS: {e}")
159308
return []
160309

161-
# 이전에 처리된 URL 로드
162-
processed_urls = load_processed_urls()
163-
print(f"Loaded {len(processed_urls)} previously processed URLs")
164-
165310
articles = []
166311
now = datetime.now()
167-
cutoff_time = now - timedelta(hours=6) # 6시간 이내 신규 아티클만 수집
168-
cutoff_time = cutoff_time.replace(tzinfo=None)
169-
170-
new_urls = set() # 이번에 처리할 URL들
312+
cutoff_time = (now - timedelta(hours=6)).replace(tzinfo=None)
171313

172314
for entry in feed.entries[:30]: # 더 많은 항목 처리 (30개)
173315
try:
316+
link = normalize_url(entry.link)
317+
if not link:
318+
continue
319+
174320
# URL 중복 체크
175-
if entry.link in processed_urls:
321+
if link in processed_urls:
176322
continue
177323

178324
# 발행일 파싱
@@ -184,10 +330,7 @@ def collect_rss_articles():
184330
else:
185331
continue
186332

187-
# timezone-naive로 변환하여 비교
188333
published_at_naive = published_at.replace(tzinfo=None)
189-
190-
# 6시간 이내 신규 아티클만 처리
191334
if published_at_naive < cutoff_time:
192335
continue
193336

@@ -201,10 +344,10 @@ def collect_rss_articles():
201344
# 기본 아티클 정보
202345
article = {
203346
'title': entry.title,
204-
'url': entry.link,
347+
'url': link,
205348
'upvotes': 0, # 기본값
206349
'comments': 0, # 기본값
207-
'published_at': published_at.isoformat(),
350+
'published_at': published_at.isoformat() if published_at else datetime.now().isoformat(),
208351
'summary': summary,
209352
'source': 'GeekNews'
210353
}
@@ -225,51 +368,56 @@ def collect_rss_articles():
225368

226369
# 상세 정보 스크래핑
227370
if should_scrape:
228-
upvotes, comments = scrape_geeknews_details(entry.link)
371+
upvotes, comments = scrape_geeknews_details(link)
229372
article['upvotes'] = upvotes
230373
article['comments'] = comments
231374
print(f"Scraped details for: {entry.title[:50]}... (👍{upvotes}, 💬{comments})")
232375

233376
articles.append(article)
234-
new_urls.add(entry.link)
235377

236378
except Exception as e:
237379
print(f"Error parsing RSS entry: {e}")
238380
continue
239381

240-
# 처리된 URL 목록 업데이트
241-
processed_urls.update(new_urls)
242-
save_processed_urls(processed_urls)
243-
244382
print(f"Collected {len(articles)} new articles from RSS")
245-
print(f"Updated processed URLs count: {len(processed_urls)}")
246-
247383
return articles
248384

249385
def main():
250386
"""메인 수집 함수"""
251387
print("Starting article collection...")
388+
ensure_candidates_dir()
389+
390+
processed_urls = load_processed_urls()
391+
print(f"Loaded {len(processed_urls)} processed URLs")
252392

253393
# GeekNews 수집
254-
geeknews_articles = collect_geeknews()
394+
geeknews_articles = collect_geeknews(processed_urls)
255395
print(f"Collected {len(geeknews_articles)} articles from GeekNews")
256396

257397
# RSS 피드 수집
258-
rss_articles = collect_rss_articles()
398+
rss_articles = collect_rss_articles(processed_urls)
259399
print(f"Collected {len(rss_articles)} articles from RSS feeds")
260400

261401
# 모든 아티클 합치기
262402
all_articles = geeknews_articles + rss_articles
403+
deduped_articles = deduplicate_articles(all_articles)
404+
print(f"Deduplicated {len(all_articles)} -> {len(deduped_articles)} articles")
263405

264-
# 저장
265-
output_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'candidates')
266-
os.makedirs(output_dir, exist_ok=True)
406+
# 처리된 URL 목록 업데이트
407+
new_urls = {
408+
normalize_url(article.get('url', ''))
409+
for article in deduped_articles
410+
if normalize_url(article.get('url', ''))
411+
}
412+
processed_urls.update(new_urls)
413+
save_processed_urls(processed_urls)
414+
print(f"Updated processed URLs count: {len(processed_urls)}")
267415

268-
output_file = os.path.join(output_dir, 'collected_articles.json')
269-
with open(output_file, 'w', encoding='utf-8') as f:
270-
json.dump(all_articles, f, ensure_ascii=False, indent=2)
416+
# 저장
417+
with open(COLLECTED_ARTICLES_FILE, 'w', encoding='utf-8') as f:
418+
json.dump(deduped_articles, f, ensure_ascii=False, indent=2)
271419

272-
print(f"Saved {len(all_articles)} articles to {output_file}")
420+
print(f"Saved {len(deduped_articles)} articles to {COLLECTED_ARTICLES_FILE}")
273421

274422
if __name__ == "__main__":
275423
main()

0 commit comments

Comments
 (0)