66
77import json
88import os
9+ import re
910import requests
1011from bs4 import BeautifulSoup
1112from datetime import datetime , timedelta
1213import feedparser
1314from 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
87235def 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
98246def 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
249385def 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
274422if __name__ == "__main__" :
275423 main ()
0 commit comments