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.
@@ -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,2 @@
1
+ def main() -> None:
2
+ print("Hello from tt-news-scraper!")
@@ -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
+ }
@@ -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
@@ -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()