Spaces:
Running
Running
Commit ·
623f05d
1
Parent(s): 5441c2f
fix: extract dates from article pages via trafilatura instead of homepage HTML
Browse files- main.py +2 -0
- refresh_task.py +4 -4
- src/extractor.py +79 -5
- src/models.py +2 -0
- src/presenter.py +4 -3
- 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 |
-
|
| 37 |
-
if not text:
|
| 38 |
return None
|
| 39 |
-
|
|
|
|
| 40 |
image = self._extract_og_image(downloaded)
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 42 |
-
|
|
|
|
| 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 |
],
|