tt-news-scraper 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tt_news_scraper-0.4.3/PKG-INFO +12 -0
- tt_news_scraper-0.4.3/pyproject.toml +32 -0
- tt_news_scraper-0.4.3/pyproject.toml.orig +36 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/__init__.py +2 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/const.py +10 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/dataset_handling/__init__.py +0 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/dataset_handling/data_saving_utils.py +75 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/dataset_handling/merging_utils.py +57 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/fetching_utils/__init__.py +0 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/fetching_utils/articles.py +152 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/fetching_utils/headlines.py +505 -0
- tt_news_scraper-0.4.3/src/tt_news_scraper/main.py +103 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tt-news-scraper
|
|
3
|
+
Version: 0.4.3
|
|
4
|
+
Summary: Trinidad and Tobago news scraper using Guardian & Express as sources
|
|
5
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
6
|
+
Requires-Dist: pandas>=2.0.0
|
|
7
|
+
Requires-Dist: pytest>=9.2.0
|
|
8
|
+
Requires-Dist: requests>=2.31.0
|
|
9
|
+
Requires-Dist: responses>=0.26.3
|
|
10
|
+
Requires-Dist: ruff>=0.8.0 ; extra == 'dev'
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Provides-Extra: dev
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tt-news-scraper"
|
|
3
|
+
version = "0.4.3"
|
|
4
|
+
description = "Trinidad and Tobago news scraper using Guardian & Express as sources"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"beautifulsoup4>=4.12.0",
|
|
8
|
+
"pandas>=2.0.0",
|
|
9
|
+
"pytest>=9.2.0",
|
|
10
|
+
"requests>=2.31.0",
|
|
11
|
+
"responses>=0.26.3",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["ruff>=0.8.0"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
ttns = "tt_news_scraper.main:main"
|
|
19
|
+
|
|
20
|
+
[tool.ruff.lint]
|
|
21
|
+
ignore = [
|
|
22
|
+
"BLE001",
|
|
23
|
+
"FA100",
|
|
24
|
+
"UP045",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["ruff>=0.17.3"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tt-news-scraper"
|
|
3
|
+
version = "0.4.3"
|
|
4
|
+
description = "Trinidad and Tobago news scraper using Guardian & Express as sources"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
|
|
7
|
+
dependencies = [
|
|
8
|
+
"beautifulsoup4>=4.12.0",
|
|
9
|
+
"pandas>=2.0.0",
|
|
10
|
+
"pytest>=9.2.0",
|
|
11
|
+
"requests>=2.31.0",
|
|
12
|
+
"responses>=0.26.3",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"ruff>=0.8.0",
|
|
18
|
+
]
|
|
19
|
+
[tool.ruff.lint]
|
|
20
|
+
ignore = [
|
|
21
|
+
"BLE001", # Blind exception catches are intentional in scraper for graceful degradation
|
|
22
|
+
"FA100", # Optional type hints are okay
|
|
23
|
+
"UP045" # Optional type hints are okay
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"ruff>=0.17.3",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
ttns = "tt_news_scraper.main:main"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared configuration and constants for the news scraper."""
|
|
2
|
+
|
|
3
|
+
# Canonical column schema for unified output across all sources
|
|
4
|
+
CANONICAL_COLUMNS = ["title", "url", "author", "date", "summary"]
|
|
5
|
+
|
|
6
|
+
# Base URLs for each source
|
|
7
|
+
BASE_URLS = {
|
|
8
|
+
"express": "https://trinidadexpress.com/search/?t=article&sd=desc",
|
|
9
|
+
"guardian": "https://www.guardian.co.tt/news-6.12.0.4f1749cb44",
|
|
10
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Dataset export and saving utilities."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def save_dataset(df: pd.DataFrame, output_path: str) -> None:
|
|
12
|
+
"""Export DataFrame to CSV with unified schema.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
df: The DataFrame containing news articles
|
|
16
|
+
output_path: Path to the output CSV file
|
|
17
|
+
"""
|
|
18
|
+
df.to_csv(output_path, index=False)
|
|
19
|
+
logger.info(f"Dataset saved to {output_path}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save_dataset_with_metadata(
|
|
23
|
+
df: pd.DataFrame,
|
|
24
|
+
output_path: str,
|
|
25
|
+
metadata: Optional[dict] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Export DataFrame to CSV with embedded metadata.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
df: The DataFrame containing news articles
|
|
31
|
+
output_path: Path to the output CSV file
|
|
32
|
+
metadata: Optional dictionary of metadata (source, count, date_range, etc.)
|
|
33
|
+
"""
|
|
34
|
+
if metadata is not None and len(metadata) > 0:
|
|
35
|
+
meta_df = pd.DataFrame([metadata])
|
|
36
|
+
df_with_meta = pd.concat(
|
|
37
|
+
[meta_df.rename(columns={c: f"_meta_{c}" for c in metadata}), df],
|
|
38
|
+
axis=1,
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
df_with_meta = df
|
|
42
|
+
|
|
43
|
+
df_with_meta.to_csv(output_path, index=False)
|
|
44
|
+
logger.info(f"Dataset with metadata saved to {output_path}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_dataframe_schema(df: pd.DataFrame, expected_columns: list[str]) -> bool:
|
|
48
|
+
"""Validate that DataFrame has the expected canonical columns.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
df: The DataFrame to validate
|
|
52
|
+
expected_columns: List of expected column names in order
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if schema matches, False otherwise
|
|
56
|
+
"""
|
|
57
|
+
actual_columns = list(df.columns)
|
|
58
|
+
return actual_columns == expected_columns
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_dataframe_summary(df: pd.DataFrame) -> dict:
|
|
62
|
+
"""Get a summary statistics dictionary for the DataFrame.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
df: The DataFrame to summarize
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dictionary with row count, column names, and basic stats per column
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"row_count": len(df),
|
|
72
|
+
"column_names": list(df.columns),
|
|
73
|
+
"column_types": {col: str(type(df[col].iloc[0])) for col in df.columns},
|
|
74
|
+
"non_null_counts": {col: df[col].notna().sum() for col in df.columns},
|
|
75
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from tt_news_scraper.fetching_utils.articles import (
|
|
6
|
+
fetch_express_article_content,
|
|
7
|
+
fetch_guardian_article_content,
|
|
8
|
+
)
|
|
9
|
+
from tt_news_scraper.fetching_utils.headlines import (
|
|
10
|
+
fetch_express_headlines,
|
|
11
|
+
fetch_guardian_headlines,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_unified_dataset(
|
|
18
|
+
source: str,
|
|
19
|
+
limit: int | None = None,
|
|
20
|
+
search_term: str | None = None,
|
|
21
|
+
fetch_content: bool = False,
|
|
22
|
+
) -> pd.DataFrame:
|
|
23
|
+
"""Unified entry point for fetching headlines from any supported source."""
|
|
24
|
+
if source == "express":
|
|
25
|
+
logger.info(f"Fetching headlines from Express with limit={limit}, search_term={search_term}")
|
|
26
|
+
df = fetch_express_headlines(
|
|
27
|
+
url=None, # Let the function construct URL from params
|
|
28
|
+
limit=limit or 10,
|
|
29
|
+
search_term=search_term,
|
|
30
|
+
)
|
|
31
|
+
else: # guardian
|
|
32
|
+
logger.info(f"Fetching headlines from Guardian with search term: {search_term}")
|
|
33
|
+
df = fetch_guardian_headlines(
|
|
34
|
+
url=None, search_term=search_term, max_pages=limit or 10
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Normalize date format to ISO string for consistency
|
|
38
|
+
if not df["date"].empty:
|
|
39
|
+
df["date"] = pd.to_datetime(df["date"], errors="coerce").dt.strftime(
|
|
40
|
+
"%Y-%m-%dT%H:%M:%S"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Fetch full article content if requested
|
|
44
|
+
if fetch_content and not df.empty:
|
|
45
|
+
logger.info(f"Fetching full content for {len(df)} articles...")
|
|
46
|
+
urls = df["url"].tolist()
|
|
47
|
+
|
|
48
|
+
if source == "express":
|
|
49
|
+
content_df = fetch_express_article_content(urls)
|
|
50
|
+
else: # guardian
|
|
51
|
+
content_df = fetch_guardian_article_content(urls)
|
|
52
|
+
|
|
53
|
+
# Merge content back to headlines on URL column
|
|
54
|
+
if not content_df.empty:
|
|
55
|
+
df = pd.merge(df, content_df, on="url", how="left")
|
|
56
|
+
|
|
57
|
+
return df
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import requests
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Base domain for URL normalization
|
|
11
|
+
BASE_DOMAINS = {
|
|
12
|
+
"express": "https://trinidadexpress.com",
|
|
13
|
+
"guardian": "https://www.guardian.co.tt",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_express_article_content(urls: Iterable[str]) -> pd.DataFrame:
|
|
18
|
+
"""Fetch full article content from Trinidad Express URLs.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
urls: Iterable of article URLs to fetch.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
DataFrame with columns: url, content
|
|
25
|
+
"""
|
|
26
|
+
articles = []
|
|
27
|
+
|
|
28
|
+
# Convert input to list to handle pandas Series/DataFrame correctly
|
|
29
|
+
if hasattr(urls, "__iter__") and not isinstance(urls, str):
|
|
30
|
+
url_list = [str(u).strip() for u in urls]
|
|
31
|
+
else:
|
|
32
|
+
url_list = [urls] if urls else []
|
|
33
|
+
|
|
34
|
+
for url in url_list:
|
|
35
|
+
if not url:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
logger.debug(f"Fetching Express article: {url}")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
response = requests.get(
|
|
42
|
+
url,
|
|
43
|
+
headers={
|
|
44
|
+
"User-Agent": (
|
|
45
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
46
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
timeout=30,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if not response.ok:
|
|
53
|
+
logger.warning(f"Failed to fetch Express article {url}: HTTP {response.status_code}")
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
57
|
+
|
|
58
|
+
# Extract article content from main article body
|
|
59
|
+
# TT Express uses .tnt-article-content or similar selectors
|
|
60
|
+
content_divs = soup.select(".tnt-article-content, .article-body, article p")
|
|
61
|
+
if not content_divs:
|
|
62
|
+
logger.debug(f"No content found in Express article {url}")
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Join all paragraphs into a single content string
|
|
66
|
+
content_parts = [div.get_text(strip=True) for div in content_divs]
|
|
67
|
+
content = "\n".join(content_parts).strip()
|
|
68
|
+
|
|
69
|
+
if not content:
|
|
70
|
+
logger.debug(f"Empty content extracted from Express article {url}")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
articles.append({"url": url, "content": content})
|
|
74
|
+
|
|
75
|
+
except requests.RequestException as e:
|
|
76
|
+
logger.error(f"Request failed for Express article {url}: {e}")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.error(f"Error processing Express article {url}: {e}")
|
|
79
|
+
|
|
80
|
+
if not articles:
|
|
81
|
+
logger.warning("No articles were successfully fetched from Express.")
|
|
82
|
+
|
|
83
|
+
return pd.DataFrame(articles, columns=["url", "content"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fetch_guardian_article_content(urls: Iterable[str]) -> pd.DataFrame:
|
|
87
|
+
"""Fetch full article content from Trinidad Guardian URLs.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
urls: Iterable of article URLs to fetch.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
DataFrame with columns: url, content
|
|
94
|
+
"""
|
|
95
|
+
articles = []
|
|
96
|
+
|
|
97
|
+
# Convert input to list to handle pandas Series/DataFrame correctly
|
|
98
|
+
if hasattr(urls, "__iter__") and not isinstance(urls, str):
|
|
99
|
+
url_list = [str(u).strip() for u in urls]
|
|
100
|
+
else:
|
|
101
|
+
url_list = [urls] if urls else []
|
|
102
|
+
|
|
103
|
+
for url in url_list:
|
|
104
|
+
if not url:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
logger.debug(f"Fetching Guardian article: {url}")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
response = requests.get(
|
|
111
|
+
url,
|
|
112
|
+
headers={
|
|
113
|
+
"User-Agent": (
|
|
114
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
115
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
timeout=30,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if not response.ok:
|
|
122
|
+
logger.warning(f"Failed to fetch Guardian article {url}: HTTP {response.status_code}")
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
126
|
+
|
|
127
|
+
# Extract article content from main body
|
|
128
|
+
# Guardian uses .article-body or similar selectors
|
|
129
|
+
content_divs = soup.select(".article-body p, .story-content p, article p")
|
|
130
|
+
if not content_divs:
|
|
131
|
+
logger.debug(f"No content found in Guardian article {url}")
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Join all paragraphs into a single content string
|
|
135
|
+
content_parts = [div.get_text(strip=True) for div in content_divs]
|
|
136
|
+
content = "\n".join(content_parts).strip()
|
|
137
|
+
|
|
138
|
+
if not content:
|
|
139
|
+
logger.debug(f"Empty content extracted from Guardian article {url}")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
articles.append({"url": url, "content": content})
|
|
143
|
+
|
|
144
|
+
except requests.RequestException as e:
|
|
145
|
+
logger.error(f"Request failed for Guardian article {url}: {e}")
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Error processing Guardian article {url}: {e}")
|
|
148
|
+
|
|
149
|
+
if not articles:
|
|
150
|
+
logger.warning("No articles were successfully fetched from Guardian.")
|
|
151
|
+
|
|
152
|
+
return pd.DataFrame(articles, columns=["url", "content"])
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import requests
|
|
8
|
+
from bs4 import BeautifulSoup, Tag
|
|
9
|
+
|
|
10
|
+
from tt_news_scraper.const import BASE_URLS, CANONICAL_COLUMNS
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch_express_headlines(
|
|
16
|
+
url: str | None = None, limit: int | None = None, search_term: str | None = None
|
|
17
|
+
) -> pd.DataFrame:
|
|
18
|
+
"""Fetch article headlines and metadata from TT Express page.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
url: Optional Trinidad Express search URL to scrape. Defaults to homepage.
|
|
22
|
+
Supports both homepage lists and search result URLs (e.g., ?q=keyword).
|
|
23
|
+
limit: Maximum number of articles to fetch per page. Only applies when scraping
|
|
24
|
+
search result URLs with pagination support.
|
|
25
|
+
search_term: Search keyword for Express mode (used in URL construction if url not provided).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
DataFrame with columns: title, url, author, date, summary
|
|
29
|
+
"""
|
|
30
|
+
# Construct URL if not provided (supports search_term for Express q-param)
|
|
31
|
+
if url is None:
|
|
32
|
+
base = BASE_URLS["express"]
|
|
33
|
+
params = ["&nsa=eedition"] # Required param for TT Express
|
|
34
|
+
if limit:
|
|
35
|
+
params.append(f"&l={limit}")
|
|
36
|
+
if search_term:
|
|
37
|
+
from urllib.parse import quote_plus
|
|
38
|
+
|
|
39
|
+
params.append(f"&q={quote_plus(search_term)}")
|
|
40
|
+
url = base + "".join(params)
|
|
41
|
+
|
|
42
|
+
# Handle local file paths for testing (requests doesn't support file:// scheme)
|
|
43
|
+
if url.startswith("file://"):
|
|
44
|
+
with open(url, "r", encoding="utf-8") as f:
|
|
45
|
+
html_content = f.read()
|
|
46
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
47
|
+
else:
|
|
48
|
+
headers = {
|
|
49
|
+
"User-Agent": (
|
|
50
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
51
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
except requests.RequestException as e:
|
|
59
|
+
logger.error(f"Failed to fetch {url}: {e}")
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
63
|
+
articles = []
|
|
64
|
+
|
|
65
|
+
# Find all article cards first (each is a <article> element)
|
|
66
|
+
article_cards = soup.select("article.tnt-asset-type-article")
|
|
67
|
+
|
|
68
|
+
# Apply limit if specified (respect Guardian's pagination-style fetching)
|
|
69
|
+
effective_limit = min(len(article_cards), limit or len(article_cards))
|
|
70
|
+
for card in article_cards[:effective_limit]:
|
|
71
|
+
headline_link = card.select_one(".card-headline h3 a")
|
|
72
|
+
if not headline_link:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
title_tag: Tag | None = headline_link.find_parent("h3") or headline_link
|
|
76
|
+
|
|
77
|
+
title = (title_tag.get_text(strip=True) or "").strip()
|
|
78
|
+
if not title:
|
|
79
|
+
continue # Skip entries without a title
|
|
80
|
+
|
|
81
|
+
# Extract the partial article link from href attribute
|
|
82
|
+
partial_link = str(headline_link.get("href", "")).strip()
|
|
83
|
+
|
|
84
|
+
# Only append if we have both title and a valid link
|
|
85
|
+
if not partial_link:
|
|
86
|
+
continue # Skip entries without a link
|
|
87
|
+
|
|
88
|
+
# Extract author (blank if missing)
|
|
89
|
+
author_elem = card.select_one(".card-byline span.tnt-byline.asset-byline")
|
|
90
|
+
author = author_elem.get_text(strip=True) if author_elem else ""
|
|
91
|
+
|
|
92
|
+
# Extract section type ("General" if missing, preserve exact text when present)
|
|
93
|
+
section_elem = card.select_one(".card-label-section a.tnt-section-tag")
|
|
94
|
+
section_type = section_elem.text.strip() if section_elem else "General"
|
|
95
|
+
|
|
96
|
+
# Extract published_date (ISO format from datetime attribute)
|
|
97
|
+
time_tag = card.select_one("time[datetime]")
|
|
98
|
+
published_date = ""
|
|
99
|
+
if time_tag and time_tag.has_attr("datetime"):
|
|
100
|
+
try:
|
|
101
|
+
dt_str = time_tag["datetime"]
|
|
102
|
+
parsed_dt = datetime.fromisoformat(dt_str)
|
|
103
|
+
published_date = parsed_dt.isoformat()
|
|
104
|
+
except ValueError:
|
|
105
|
+
pass # Keep empty on parse failure
|
|
106
|
+
|
|
107
|
+
# Extract article summary (full paragraph, not truncated)
|
|
108
|
+
lead_p = card.select_one(".card-lead p.tnt-summary")
|
|
109
|
+
article_summary = lead_p.get_text(strip=True) if lead_p else ""
|
|
110
|
+
|
|
111
|
+
base_domain = "https://trinidadexpress.com"
|
|
112
|
+
articles.append(
|
|
113
|
+
{
|
|
114
|
+
"title": title,
|
|
115
|
+
"url": base_domain + partial_link,
|
|
116
|
+
"author": author,
|
|
117
|
+
"section_type": section_type,
|
|
118
|
+
"date": published_date,
|
|
119
|
+
"summary": article_summary,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Log skipped entries
|
|
124
|
+
total_cards = len(article_cards)
|
|
125
|
+
extracted_count = len(articles)
|
|
126
|
+
skipped = max(0, total_cards - extracted_count)
|
|
127
|
+
|
|
128
|
+
if skipped > 0:
|
|
129
|
+
logger.info(f"Filtered out {skipped} non-article card(s)")
|
|
130
|
+
|
|
131
|
+
# Create DataFrame and rename columns to canonical schema before returning
|
|
132
|
+
df = pd.DataFrame(articles, columns=["url", "title", "summary", "author", "date"])
|
|
133
|
+
return _rename_express_columns(df)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _rename_express_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
137
|
+
"""Rename Express-specific column names to canonical schema."""
|
|
138
|
+
col_map = {
|
|
139
|
+
"section_type": None, # Drop unused column
|
|
140
|
+
}
|
|
141
|
+
df = df.drop(
|
|
142
|
+
columns=[col for col in col_map if col_map[col] is not None], errors="ignore"
|
|
143
|
+
)
|
|
144
|
+
return _rename_guardian_columns(df)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fetch_guardian_headlines(
|
|
148
|
+
url: str | None = None,
|
|
149
|
+
search_term: str | None = None,
|
|
150
|
+
max_pages: int = 10,
|
|
151
|
+
) -> pd.DataFrame:
|
|
152
|
+
"""Scrape Trinidad Guardian news headlines and descriptions.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
url: Optional URL to scrape. Defaults to the homepage list page.
|
|
156
|
+
Supports both homepage lists and search result pages (e.g.,
|
|
157
|
+
?search_content=keyword).
|
|
158
|
+
max_pages: Maximum number of pagination pages to fetch for search results.
|
|
159
|
+
Only applies when scraping search result URLs.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
pandas.DataFrame with columns: url, headline, description, author, date
|
|
163
|
+
"""
|
|
164
|
+
# Construct URL if search_term is provided without a base URL
|
|
165
|
+
if search_term and url is None:
|
|
166
|
+
url = (
|
|
167
|
+
f"{BASE_URLS['guardian']}?search_content={urllib.parse.quote(search_term)}"
|
|
168
|
+
)
|
|
169
|
+
elif url is None:
|
|
170
|
+
url = BASE_URLS["guardian"]
|
|
171
|
+
|
|
172
|
+
# Fetch page with proper headers to avoid bot detection
|
|
173
|
+
|
|
174
|
+
response = requests.get(
|
|
175
|
+
url,
|
|
176
|
+
headers={
|
|
177
|
+
"User-Agent": (
|
|
178
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
179
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
180
|
+
),
|
|
181
|
+
},
|
|
182
|
+
timeout=30,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if not response.ok:
|
|
186
|
+
logger.warning(f"Got HTTP {response.status_code} for {url}, retrying in 2s...")
|
|
187
|
+
|
|
188
|
+
time.sleep(2)
|
|
189
|
+
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=30)
|
|
190
|
+
|
|
191
|
+
if not response.ok:
|
|
192
|
+
logger.error(
|
|
193
|
+
f"Failed to fetch Guardian page {url}: HTTP {response.status_code}"
|
|
194
|
+
)
|
|
195
|
+
raise requests.HTTPError(
|
|
196
|
+
f"Guardian returned {response.status_code}", response=response
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
# Use response.text directly (requests handles decoding for us)
|
|
201
|
+
html_content = (response.text or "").strip()
|
|
202
|
+
if not html_content:
|
|
203
|
+
logger.warning(f"Empty response from Guardian page {url}")
|
|
204
|
+
return pd.DataFrame(columns=CANONICAL_COLUMNS)
|
|
205
|
+
|
|
206
|
+
# Debug output
|
|
207
|
+
print(f"[DEBUG] HTML length: {len(html_content)}")
|
|
208
|
+
print(f"[DEBUG] First 200 chars: {html_content[:200]}")
|
|
209
|
+
|
|
210
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error(f"Failed to parse HTML for {url}: {e}")
|
|
213
|
+
return pd.DataFrame(columns=CANONICAL_COLUMNS)
|
|
214
|
+
|
|
215
|
+
articles = []
|
|
216
|
+
|
|
217
|
+
# Find all list containers (contain article links)
|
|
218
|
+
for container in soup.find_all(class_=lambda x: x and "_LIST" in str(x)):
|
|
219
|
+
# Skip sponsored content
|
|
220
|
+
if "sponsored-list" in str(container.get("class", [])):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
for link in container.find_all("a", href=True):
|
|
224
|
+
# Extract headline from h1 inside the anchor
|
|
225
|
+
headline_elem = link.find("h1", class_="headline")
|
|
226
|
+
|
|
227
|
+
if not headline_elem:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
headline = headline_elem.get_text(strip=True)
|
|
231
|
+
|
|
232
|
+
# Skip pagination links (short text, numbers/arrows only)
|
|
233
|
+
link_text = link.get_text(strip=True)
|
|
234
|
+
if (
|
|
235
|
+
len(headline) < 20
|
|
236
|
+
or headline.isdigit()
|
|
237
|
+
or "→" in link_text
|
|
238
|
+
or "»" in link_text
|
|
239
|
+
):
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Get description from text before the headline (author/caption info)
|
|
243
|
+
parent_div = link.find_parent("div")
|
|
244
|
+
all_text = parent_div.get_text(strip=True) if parent_div else ""
|
|
245
|
+
|
|
246
|
+
# Description is everything between container start and headline
|
|
247
|
+
headline_pos = all_text.find(headline)
|
|
248
|
+
description = all_text[:headline_pos].strip() if headline_pos > 0 else ""
|
|
249
|
+
|
|
250
|
+
# Clean up description (remove extra whitespace, author names in brackets)
|
|
251
|
+
description = " ".join(description.split())
|
|
252
|
+
|
|
253
|
+
# Extract author (pattern: "byAuthorName" after headline)
|
|
254
|
+
import re
|
|
255
|
+
|
|
256
|
+
author_match = re.search(r"by(\S+)", all_text, re.IGNORECASE)
|
|
257
|
+
author = author_match.group(1).strip() if author_match else ""
|
|
258
|
+
|
|
259
|
+
# Extract timestamp (format: YYYYMMDDHHMMSS at end of metadata section)
|
|
260
|
+
timestamp_match = re.search(r"(\d{14})\b", all_text)
|
|
261
|
+
article_date = pd.NaT
|
|
262
|
+
if timestamp_match and len(timestamp_match.group(1)) == 14:
|
|
263
|
+
try:
|
|
264
|
+
dt = datetime.strptime(timestamp_match.group(1), "%Y%m%d%H%M%S")
|
|
265
|
+
article_date = dt
|
|
266
|
+
except ValueError:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
articles.append(
|
|
270
|
+
{
|
|
271
|
+
"url": link["href"],
|
|
272
|
+
"headline": headline,
|
|
273
|
+
"description": description[:200]
|
|
274
|
+
if len(description) > 200
|
|
275
|
+
else description,
|
|
276
|
+
"author": author,
|
|
277
|
+
"date": article_date,
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Only use pagination for search result URLs (those with query params), not homepage
|
|
282
|
+
if url is not None and isinstance(url, str) and "?" in url:
|
|
283
|
+
if soup.find(class_=lambda x: x and "ag_page_navigation" in str(x)):
|
|
284
|
+
articles = scrape_all_guardian_pages(url, max_pages)
|
|
285
|
+
|
|
286
|
+
# Convert URLs to absolute format for consistency
|
|
287
|
+
base_domain = "https://www.guardian.co.tt"
|
|
288
|
+
df = pd.DataFrame(articles).copy()
|
|
289
|
+
if not df.empty:
|
|
290
|
+
df["url"] = [
|
|
291
|
+
base_domain + url if not url.startswith("http") else url
|
|
292
|
+
for url in df["url"]
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
# Rename columns to canonical schema before returning
|
|
296
|
+
return _rename_guardian_columns(df)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _rename_guardian_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
300
|
+
"""Rename Guardian-specific column names to canonical schema."""
|
|
301
|
+
col_map = {
|
|
302
|
+
"headline": "title",
|
|
303
|
+
"description": "summary",
|
|
304
|
+
}
|
|
305
|
+
df.rename(columns=col_map, inplace=True)
|
|
306
|
+
return df
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def scrape_all_guardian_pages(base_url: str, max_pages: int = 10) -> list[dict]:
|
|
310
|
+
"""Fetch all pagination pages for a search result URL.
|
|
311
|
+
|
|
312
|
+
Uses iterative link-following instead of URL construction to handle
|
|
313
|
+
Guardian's dynamic pagination structure correctly.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
base_url: The original search result URL (e.g., with ?search_content=...)
|
|
317
|
+
max_pages: Maximum number of pagination pages to fetch
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
list of article dicts from all pages
|
|
321
|
+
"""
|
|
322
|
+
all_articles = []
|
|
323
|
+
visited_urls = set()
|
|
324
|
+
page_queue = [base_url] # Queue for iterative BFS-style traversal
|
|
325
|
+
|
|
326
|
+
while page_queue and len(all_articles) < max_pages * 20: # ~20 articles per page
|
|
327
|
+
current_url = page_queue.pop(0)
|
|
328
|
+
|
|
329
|
+
if current_url in visited_urls:
|
|
330
|
+
continue
|
|
331
|
+
visited_urls.add(current_url)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
import time
|
|
335
|
+
|
|
336
|
+
time.sleep(1) # Polite delay between pagination requests
|
|
337
|
+
|
|
338
|
+
response = requests.get(
|
|
339
|
+
current_url,
|
|
340
|
+
headers={
|
|
341
|
+
"User-Agent": (
|
|
342
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
343
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
timeout=30,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if not response.ok:
|
|
350
|
+
logger.error(
|
|
351
|
+
f"Failed to fetch Guardian page {current_url}: HTTP {response.status_code}"
|
|
352
|
+
)
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
html = (response.text or "").strip()
|
|
356
|
+
|
|
357
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
358
|
+
|
|
359
|
+
# Scrape articles from current page and extend the list
|
|
360
|
+
scraped_df = _scrape_single_guardian_page(current_url, visited_urls)
|
|
361
|
+
all_articles.extend(scraped_df.to_dict(orient="records"))
|
|
362
|
+
|
|
363
|
+
# Extract pagination links and add new pages to queue
|
|
364
|
+
for nav_elem in soup.find_all(
|
|
365
|
+
class_=lambda x: x and "ag_page_navigation" in str(x)
|
|
366
|
+
):
|
|
367
|
+
for a in nav_elem.find_all("a", href=True):
|
|
368
|
+
link_text = a.get_text(strip=True)
|
|
369
|
+
if any(c.isdigit() for c in link_text) and len(link_text) > 1:
|
|
370
|
+
new_url = urllib.parse.urljoin(current_url, a["href"])
|
|
371
|
+
if new_url not in visited_urls:
|
|
372
|
+
page_queue.append(new_url)
|
|
373
|
+
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.error(f"Error fetching {current_url}: {e}")
|
|
376
|
+
|
|
377
|
+
# Convert URLs to absolute format for consistency
|
|
378
|
+
base_domain = "https://www.guardian.co.tt"
|
|
379
|
+
df = pd.DataFrame(all_articles).copy()
|
|
380
|
+
if not df.empty:
|
|
381
|
+
df["url"] = [
|
|
382
|
+
base_domain + url if not url.startswith("http") else url
|
|
383
|
+
for url in df["url"]
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
return df
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _scrape_single_guardian_page(url: str, visited_urls: set[str]) -> pd.DataFrame:
|
|
390
|
+
"""Scrape articles from a single page.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
url: The page URL to scrape
|
|
394
|
+
visited_urls: Set of already-visited URLs (for deduplication)
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
list of article dicts
|
|
398
|
+
"""
|
|
399
|
+
articles = []
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
import time
|
|
403
|
+
|
|
404
|
+
time.sleep(1) # Polite delay between requests
|
|
405
|
+
|
|
406
|
+
response = requests.get(
|
|
407
|
+
url,
|
|
408
|
+
headers={
|
|
409
|
+
"User-Agent": (
|
|
410
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
411
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
412
|
+
),
|
|
413
|
+
},
|
|
414
|
+
timeout=30,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if not response.ok:
|
|
418
|
+
logger.error(
|
|
419
|
+
f"Failed to fetch Guardian page {url}: HTTP {response.status_code}"
|
|
420
|
+
)
|
|
421
|
+
return pd.DataFrame(
|
|
422
|
+
columns=["url", "headline", "description", "author", "date"]
|
|
423
|
+
)
|
|
424
|
+
html = (response.text or "").strip()
|
|
425
|
+
|
|
426
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
427
|
+
|
|
428
|
+
# Scrape articles from list containers
|
|
429
|
+
for container in soup.find_all(class_=lambda x: x and "_LIST" in str(x)):
|
|
430
|
+
if "sponsored-list" in str(container.get("class", [])):
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
for link in container.find_all("a", href=True):
|
|
434
|
+
headline_elem = link.find("h1", class_="headline")
|
|
435
|
+
|
|
436
|
+
if not headline_elem:
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
headline = headline_elem.get_text(strip=True)
|
|
440
|
+
link_text = link.get_text(strip=True)
|
|
441
|
+
|
|
442
|
+
# Skip pagination links and short/numeric text
|
|
443
|
+
if (
|
|
444
|
+
len(headline) < 20
|
|
445
|
+
or headline.isdigit()
|
|
446
|
+
or "→" in link_text
|
|
447
|
+
or "»" in link_text
|
|
448
|
+
):
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
parent_div = link.find_parent("div")
|
|
452
|
+
all_text = parent_div.get_text(strip=True) if parent_div else ""
|
|
453
|
+
headline_pos = all_text.find(headline)
|
|
454
|
+
description = (
|
|
455
|
+
all_text[:headline_pos].strip() if headline_pos > 0 else ""
|
|
456
|
+
)
|
|
457
|
+
description = " ".join(description.split())
|
|
458
|
+
|
|
459
|
+
# Extract author and date from metadata section
|
|
460
|
+
import re
|
|
461
|
+
|
|
462
|
+
parent_div = link.find_parent("div")
|
|
463
|
+
all_text = parent_div.get_text(strip=True) if parent_div else ""
|
|
464
|
+
|
|
465
|
+
author_match = re.search(r"\bby\s+(\S+)", all_text, re.IGNORECASE)
|
|
466
|
+
author = author_match.group(1).strip() if author_match else ""
|
|
467
|
+
|
|
468
|
+
timestamp_match = re.search(r"(\d{14})\b", all_text)
|
|
469
|
+
article_date = pd.NaT
|
|
470
|
+
if timestamp_match and len(timestamp_match.group(1)) == 14:
|
|
471
|
+
try:
|
|
472
|
+
dt = datetime.strptime(timestamp_match.group(1), "%Y%m%d%H%M%S")
|
|
473
|
+
article_date = dt
|
|
474
|
+
except ValueError:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
article = {
|
|
478
|
+
"url": link["href"],
|
|
479
|
+
"headline": headline,
|
|
480
|
+
"description": description[:200]
|
|
481
|
+
if len(description) > 200
|
|
482
|
+
else description,
|
|
483
|
+
"author": author,
|
|
484
|
+
"date": article_date,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
# Only add if URL not already visited (prevents duplicates across pages)
|
|
488
|
+
if article["url"] not in visited_urls:
|
|
489
|
+
articles.append(article)
|
|
490
|
+
visited_urls.add(article["url"])
|
|
491
|
+
|
|
492
|
+
except Exception as e:
|
|
493
|
+
logger.error(f"Error scraping {url}: {e}")
|
|
494
|
+
|
|
495
|
+
# Convert URLs to absolute format for consistency
|
|
496
|
+
base_domain = "https://www.guardian.co.tt"
|
|
497
|
+
df = pd.DataFrame(articles).copy()
|
|
498
|
+
if not df.empty:
|
|
499
|
+
df["url"] = [
|
|
500
|
+
base_domain + url if not url.startswith("http") else url
|
|
501
|
+
for url in df["url"]
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
# Rename columns to canonical schema before returning
|
|
505
|
+
return _rename_guardian_columns(df)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Trinidad & Tobago News Scraper - Fetches headlines from Trinidad Express and Guardian by source."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from tt_news_scraper.dataset_handling.data_saving_utils import save_dataset
|
|
9
|
+
from tt_news_scraper.dataset_handling.merging_utils import fetch_unified_dataset
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""CLI entry point."""
|
|
16
|
+
import argparse
|
|
17
|
+
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
description="Scrape Trinidad news headlines from Express or Guardian by source",
|
|
20
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-v",
|
|
24
|
+
"--version",
|
|
25
|
+
action="version",
|
|
26
|
+
version=f"tt-news-scraper v{metadata.version('tt-news-scraper')}",
|
|
27
|
+
help="Show the program's version number and exit.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--source",
|
|
31
|
+
choices=["express", "guardian"],
|
|
32
|
+
default="express",
|
|
33
|
+
help="Data source to fetch from (default: express)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--limit",
|
|
37
|
+
type=int,
|
|
38
|
+
nargs="?",
|
|
39
|
+
const=10,
|
|
40
|
+
metavar="N",
|
|
41
|
+
help="Number of articles/pages to fetch (default: 10)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--search-term",
|
|
45
|
+
type=str,
|
|
46
|
+
default=None,
|
|
47
|
+
metavar="KEYWORD",
|
|
48
|
+
help='Search keyword for Express (q param) or Guardian mode (e.g., "politics")',
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--output",
|
|
52
|
+
type=str,
|
|
53
|
+
default="tt_news_headlines.csv",
|
|
54
|
+
metavar="FILE",
|
|
55
|
+
help="Output CSV filename (default: tt_news_headlines.csv)",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--content",
|
|
59
|
+
action="store_true",
|
|
60
|
+
default=False,
|
|
61
|
+
help="Fetch full article content in addition to headlines (slower, ~3x fetch time per article)",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
args = parser.parse_args()
|
|
65
|
+
|
|
66
|
+
# Fetch unified dataset using the wrapper function
|
|
67
|
+
df = fetch_unified_dataset(
|
|
68
|
+
source=args.source,
|
|
69
|
+
limit=args.limit,
|
|
70
|
+
search_term=args.search_term,
|
|
71
|
+
fetch_content=args.content,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Export with unified schema
|
|
75
|
+
save_dataset(df, args.output)
|
|
76
|
+
|
|
77
|
+
if df.empty:
|
|
78
|
+
logger.warning("No articles found or all entries were malformed.")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
print("\n" + "=" * 80)
|
|
82
|
+
print(f"Found {len(df)} article(s)")
|
|
83
|
+
print("=" * 80)
|
|
84
|
+
|
|
85
|
+
# Display as a nicely formatted table using canonical column names
|
|
86
|
+
if len(df) > 0:
|
|
87
|
+
print("\n")
|
|
88
|
+
|
|
89
|
+
for idx, row in df.iterrows():
|
|
90
|
+
title = str(row["title"])[:60] if pd.notna(row["title"]) else ""
|
|
91
|
+
link = (
|
|
92
|
+
str(row["url"])[:70]
|
|
93
|
+
if pd.notna(row["url"]) and len(str(row["url"])) > 0
|
|
94
|
+
else ""
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
print(f"[{idx}] {title} | {link}")
|
|
98
|
+
|
|
99
|
+
print("\n" + "=" * 80)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|