skander101 commited on
Commit
623f05d
·
1 Parent(s): 5441c2f

fix: extract dates from article pages via trafilatura instead of homepage HTML

Browse files
Files changed (6) hide show
  1. main.py +2 -0
  2. refresh_task.py +4 -4
  3. src/extractor.py +79 -5
  4. src/models.py +2 -0
  5. src/presenter.py +4 -3
  6. webapp.py +4 -4
main.py CHANGED
@@ -66,6 +66,8 @@ def run_pipeline(items: list[NewsItem], cfg: Config, skip_extraction: bool = Fal
66
  source_domain=item.post.source_domain or "reddit.com",
67
  extraction_success=False,
68
  image_url=item.post.image_url,
 
 
69
  )
70
  logger.info(" [%2d/%d] %-60s ✗ (title only)", i, len(items), item.post.title[:60])
71
  item.article = article
 
66
  source_domain=item.post.source_domain or "reddit.com",
67
  extraction_success=False,
68
  image_url=item.post.image_url,
69
+ published=item.post.published,
70
+ published_iso=item.post.published_iso,
71
  )
72
  logger.info(" [%2d/%d] %-60s ✗ (title only)", i, len(items), item.post.title[:60])
73
  item.article = article
refresh_task.py CHANGED
@@ -61,8 +61,8 @@ def serialize_clusters(clusters):
61
  "comments": a.post.num_comments,
62
  "url": a.post.url,
63
  "image": a.article.image_url if a.article else a.post.image_url,
64
- "published": a.post.published,
65
- "published_iso": a.post.published_iso,
66
  }
67
  for a in c.articles[:5]
68
  ],
@@ -92,8 +92,8 @@ def serialize_clusters(clusters):
92
  "score": a.post.score,
93
  "comments": a.post.num_comments,
94
  "image": a.article.image_url if a.article else a.post.image_url,
95
- "published": a.post.published,
96
- "published_iso": a.post.published_iso,
97
  }
98
  for a in c.articles[:5]
99
  ],
 
61
  "comments": a.post.num_comments,
62
  "url": a.post.url,
63
  "image": a.article.image_url if a.article else a.post.image_url,
64
+ "published": a.article.published or a.post.published,
65
+ "published_iso": a.article.published_iso or a.post.published_iso,
66
  }
67
  for a in c.articles[:5]
68
  ],
 
92
  "score": a.post.score,
93
  "comments": a.post.num_comments,
94
  "image": a.article.image_url if a.article else a.post.image_url,
95
+ "published": a.article.published or a.post.published,
96
+ "published_iso": a.article.published_iso or a.post.published_iso,
97
  }
98
  for a in c.articles[:5]
99
  ],
src/extractor.py CHANGED
@@ -33,12 +33,17 @@ class ArticleExtractor:
33
  downloaded = self._trafilatura.fetch_url(url)
34
  if not downloaded:
35
  return None
36
- text = self._trafilatura.extract(downloaded)
37
- if not text:
38
  return None
39
- title = self._extract_title_meta(downloaded) or ""
 
40
  image = self._extract_og_image(downloaded)
41
- return Article(url=url, title=title, text=text, source_domain=domain, image_url=image)
 
 
 
 
42
 
43
  def _extract_fallback(self, url: str, domain: str) -> Optional[Article]:
44
  resp = requests.get(url, headers={"User-Agent": "newsapp/1.0"}, timeout=15)
@@ -50,12 +55,40 @@ class ArticleExtractor:
50
  title = soup.title.get_text(strip=True)
51
 
52
  image = self._extract_og_image_soup(soup)
 
53
 
54
  paragraphs = soup.find_all("p")
55
  text = "\n\n".join(p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True))
56
  if not text:
57
  return None
58
- return Article(url=url, title=title, text=text, source_domain=domain, image_url=image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  @staticmethod
61
  def _extract_title_meta(html: str) -> Optional[str]:
@@ -79,6 +112,47 @@ class ArticleExtractor:
79
  )
80
  return m.group(1) if m else ""
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @staticmethod
83
  def _extract_og_image_soup(soup) -> str:
84
  for prop in ("og:image", "twitter:image"):
 
33
  downloaded = self._trafilatura.fetch_url(url)
34
  if not downloaded:
35
  return None
36
+ result = self._trafilatura.extract(downloaded, output_format='python')
37
+ if not result or not result.get('text'):
38
  return None
39
+ text = result['text']
40
+ title = result.get('title') or self._extract_title_meta(downloaded) or ""
41
  image = self._extract_og_image(downloaded)
42
+ published, published_iso = self._parse_trafilatura_date(result.get('date'))
43
+ return Article(
44
+ url=url, title=title, text=text, source_domain=domain,
45
+ image_url=image, published=published, published_iso=published_iso,
46
+ )
47
 
48
  def _extract_fallback(self, url: str, domain: str) -> Optional[Article]:
49
  resp = requests.get(url, headers={"User-Agent": "newsapp/1.0"}, timeout=15)
 
55
  title = soup.title.get_text(strip=True)
56
 
57
  image = self._extract_og_image_soup(soup)
58
+ published, published_iso = self._extract_fallback_date(soup)
59
 
60
  paragraphs = soup.find_all("p")
61
  text = "\n\n".join(p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True))
62
  if not text:
63
  return None
64
+ return Article(
65
+ url=url, title=title, text=text, source_domain=domain,
66
+ image_url=image, published=published, published_iso=published_iso,
67
+ )
68
+
69
+ @staticmethod
70
+ def _parse_trafilatura_date(raw_date: str | None) -> tuple[str, str]:
71
+ if not raw_date:
72
+ return ("", "")
73
+ from datetime import datetime
74
+ for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
75
+ try:
76
+ dt = datetime.strptime(raw_date.strip(), fmt)
77
+ return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
78
+ except ValueError:
79
+ continue
80
+ try:
81
+ dt = datetime.fromisoformat(raw_date.strip())
82
+ return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
83
+ except (ValueError, TypeError):
84
+ pass
85
+ if len(raw_date) >= 10:
86
+ try:
87
+ dt = datetime.strptime(raw_date[:10], "%Y-%m-%d")
88
+ return (dt.strftime("%d %b %Y"), raw_date[:10])
89
+ except ValueError:
90
+ pass
91
+ return ("", "")
92
 
93
  @staticmethod
94
  def _extract_title_meta(html: str) -> Optional[str]:
 
112
  )
113
  return m.group(1) if m else ""
114
 
115
+ @staticmethod
116
+ def _extract_fallback_date(soup) -> tuple[str, str]:
117
+ from datetime import datetime
118
+ time_tag = soup.find("time")
119
+ if time_tag:
120
+ raw = time_tag.get("datetime") or time_tag.get_text(strip=True)
121
+ if raw:
122
+ for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
123
+ try:
124
+ dt = datetime.strptime(raw.strip(), fmt)
125
+ return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
126
+ except ValueError:
127
+ continue
128
+ try:
129
+ dt = datetime.fromisoformat(raw.strip())
130
+ return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
131
+ except (ValueError, TypeError):
132
+ pass
133
+ for prop in ("article:published_time", "date"):
134
+ tag = soup.find("meta", property=prop) or soup.find("meta", attrs={"name": prop})
135
+ if tag and tag.get("content"):
136
+ raw = tag["content"].strip()
137
+ for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
138
+ try:
139
+ dt = datetime.strptime(raw, fmt)
140
+ return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
141
+ except ValueError:
142
+ continue
143
+ try:
144
+ dt = datetime.fromisoformat(raw)
145
+ return (dt.strftime("%d %b %Y"), dt.strftime("%Y-%m-%d"))
146
+ except (ValueError, TypeError):
147
+ pass
148
+ if len(raw) >= 10:
149
+ try:
150
+ dt = datetime.strptime(raw[:10], "%Y-%m-%d")
151
+ return (dt.strftime("%d %b %Y"), raw[:10])
152
+ except ValueError:
153
+ pass
154
+ return ("", "")
155
+
156
  @staticmethod
157
  def _extract_og_image_soup(soup) -> str:
158
  for prop in ("og:image", "twitter:image"):
src/models.py CHANGED
@@ -24,6 +24,8 @@ class Article:
24
  source_domain: str
25
  extraction_success: bool = True
26
  image_url: str = ""
 
 
27
 
28
 
29
  @dataclass
 
24
  source_domain: str
25
  extraction_success: bool = True
26
  image_url: str = ""
27
+ published: str = ""
28
+ published_iso: str = ""
29
 
30
 
31
  @dataclass
src/presenter.py CHANGED
@@ -26,7 +26,7 @@ class NewsPresenter:
26
 
27
  for cat in NewsPresenter.CATEGORIES:
28
  items = by_cat.get(cat, [])
29
- items.sort(key=lambda x: (x.articles[0].post.published_iso or "", x.final_score), reverse=True)
30
  items = items[:top_n]
31
  if not items:
32
  continue
@@ -38,8 +38,9 @@ class NewsPresenter:
38
  item = cluster.articles[0]
39
  title = item.article.title[:72] + "…" if item.article.title and len(item.article.title) > 72 else (item.article.title or item.post.title)
40
  print(f" {title}")
41
- if item.post.published:
42
- print(f" 📅 {item.post.published}")
 
43
  if item.analysis and item.analysis.summary:
44
  short = item.analysis.summary[:72] + "…" if len(item.analysis.summary) > 72 else item.analysis.summary
45
  print(f" → {short}")
 
26
 
27
  for cat in NewsPresenter.CATEGORIES:
28
  items = by_cat.get(cat, [])
29
+ items.sort(key=lambda x: (x.articles[0].article.published_iso or x.articles[0].post.published_iso or "", x.final_score), reverse=True)
30
  items = items[:top_n]
31
  if not items:
32
  continue
 
38
  item = cluster.articles[0]
39
  title = item.article.title[:72] + "…" if item.article.title and len(item.article.title) > 72 else (item.article.title or item.post.title)
40
  print(f" {title}")
41
+ published = item.article.published or item.post.published
42
+ if published:
43
+ print(f" 📅 {published}")
44
  if item.analysis and item.analysis.summary:
45
  short = item.analysis.summary[:72] + "…" if len(item.analysis.summary) > 72 else item.analysis.summary
46
  print(f" → {short}")
webapp.py CHANGED
@@ -66,8 +66,8 @@ def _cluster_to_html_dict(c):
66
  "score": a.post.score,
67
  "comments": a.post.num_comments,
68
  "image": a.article.image_url if a.article else a.post.image_url,
69
- "published": a.post.published,
70
- "published_iso": a.post.published_iso,
71
  }
72
  for a in c.articles[:5]
73
  ],
@@ -105,8 +105,8 @@ def _cluster_to_api_dict(c):
105
  "score": a.post.score,
106
  "comments": a.post.num_comments,
107
  "image": a.article.image_url if a.article else a.post.image_url,
108
- "published": a.post.published,
109
- "published_iso": a.post.published_iso,
110
  }
111
  for a in c.articles[:5]
112
  ],
 
66
  "score": a.post.score,
67
  "comments": a.post.num_comments,
68
  "image": a.article.image_url if a.article else a.post.image_url,
69
+ "published": a.article.published or a.post.published,
70
+ "published_iso": a.article.published_iso or a.post.published_iso,
71
  }
72
  for a in c.articles[:5]
73
  ],
 
105
  "score": a.post.score,
106
  "comments": a.post.num_comments,
107
  "image": a.article.image_url if a.article else a.post.image_url,
108
+ "published": a.article.published or a.post.published,
109
+ "published_iso": a.article.published_iso or a.post.published_iso,
110
  }
111
  for a in c.articles[:5]
112
  ],