""" Web Scraper Module - "Brute Force" Edition Designed to aggressively crawl homepage links when sitemaps fail. """ import re import time import logging import random from typing import List, Optional, Set, Dict from dataclasses import dataclass, field from urllib.parse import urljoin, urlparse import requests import warnings from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning from concurrent.futures import ThreadPoolExecutor, as_completed logger = logging.getLogger(__name__) # --- Data Models --- @dataclass class Article: url: str title: str text: str author: Optional[str] = None date: Optional[str] = None category: Optional[str] = None has_sources: bool = False source_links: List[str] = field(default_factory=list) is_opinion: bool = False @dataclass class SiteMetadata: domain: str has_about_page: bool = False about_text: str = "" ownership_disclosed: bool = False ownership_info: str = "" funding_disclosed: bool = False funding_info: str = "" location_disclosed: bool = False location_info: str = "" contact_info: str = "" has_author_pages: bool = False # --- The Scraper Class --- class MediaScraper: def __init__(self, base_url: str, max_articles: int = 30): self.base_url = base_url.rstrip('/') self.domain = urlparse(self.base_url).netloc.replace('www.', '') self.max_articles = max_articles self.visited_urls: Set[str] = set() self.session = requests.Session() # Robust Headers to look like a real browser (Chrome on Windows) self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', 'Referer': 'https://www.google.com/', } def fetch_page(self, url: str) -> Optional[BeautifulSoup]: """Downloads and parses a page safely.""" try: time.sleep(random.uniform(0.5, 1.5)) # Delay to avoid 429 Rate Limits resp = self.session.get(url, headers=self.headers, timeout=15) resp.raise_for_status() # Fix encoding issues if resp.encoding == 'ISO-8859-1': resp.encoding = resp.apparent_encoding # Suppress XML warning for RSS feeds/Sitemaps with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) soup = BeautifulSoup(resp.text, 'html.parser') return soup except Exception as e: logger.warning(f"Failed to fetch {url}: {e}") return None def _discover_rss_urls(self, soup) -> Set[str]: """Discover article URLs from RSS/Atom feeds.""" urls = set() # 1. Check for in homepage HTML feed_links = soup.find_all('link', attrs={'type': re.compile(r'application/(rss|atom)\+xml')}) feed_urls = [urljoin(self.base_url, link.get('href', '')) for link in feed_links if link.get('href')] # 2. Try common feed paths for path in ['/feed', '/feed.xml', '/rss', '/rss.xml', '/atom.xml']: feed_urls.append(f"{self.base_url}{path}") for feed_url in feed_urls: try: resp = self.session.get(feed_url, headers=self.headers, timeout=10) if resp.status_code != 200: continue content = resp.text if ' or Atom for item in feed_soup.find_all(['item', 'entry']): link_tag = item.find('link') if link_tag: href = link_tag.get('href') or link_tag.get_text(strip=True) if href and href.startswith('http'): urls.add(href) if urls: logger.info(f"RSS/Atom feed discovery found {len(urls)} URLs from {feed_url}") break # Use first successful feed except Exception as e: logger.debug(f"Feed fetch failed for {feed_url}: {e}") return urls def _discover_substack_urls(self, soup) -> Set[str]: """Discover article URLs from Substack archive pages.""" urls = set() is_substack = ( 'substack.com' in self.domain or bool(soup.find('meta', attrs={'content': re.compile(r'substack', re.I)})) or bool(soup.find('link', attrs={'href': re.compile(r'substack')})) ) if not is_substack: return urls logger.info(f"Detected Substack site: {self.base_url}") archive_url = f"{self.base_url}/archive" archive_soup = self.fetch_page(archive_url) if archive_soup: for a in archive_soup.find_all('a', href=True): href = a['href'] full_url = urljoin(self.base_url, href) if self.domain in full_url and '/p/' in full_url: urls.add(full_url) logger.info(f"Substack archive discovery found {len(urls)} URLs") return urls def _discover_sitemap_urls(self) -> Set[str]: """Discover article URLs from sitemap.xml as fallback.""" urls = set() for path in ['/sitemap.xml', '/sitemap-posts.xml', '/post-sitemap.xml']: try: resp = self.session.get(f"{self.base_url}{path}", headers=self.headers, timeout=10) if resp.status_code != 200: continue with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) sitemap_soup = BeautifulSoup(resp.text, 'html.parser') for loc in sitemap_soup.find_all('loc'): url = loc.get_text(strip=True) if url and self.domain in url and len(url) > len(self.base_url) + 10: urls.add(url) if urls: logger.info(f"Sitemap discovery found {len(urls)} URLs from {path}") break except Exception as e: logger.debug(f"Sitemap fetch failed for {path}: {e}") return urls def scrape_feed(self) -> List[Article]: """ The Main Method called by profiler.py. Strategy: Try RSS/feed first, then homepage links, with sitemap fallback. """ t_start = time.time() logger.info(f"Scraping homepage: {self.base_url}") soup = self.fetch_page(self.base_url) if not soup: logger.error(f"Could not load homepage for {self.base_url}. Site might be blocking requests.") return [] # 0. Try RSS/Atom feeds and Substack archive first candidates = self._discover_rss_urls(soup) candidates |= self._discover_substack_urls(soup) # 1. Collect all potential links from homepage for a in soup.find_all('a', href=True): href = a['href'] full_url = urljoin(self.base_url, href) # Filter logic if self.domain not in full_url: continue # Internal only if len(full_url) < len(self.base_url) + 10: continue # Too short # Skip obvious non-article pages if any(x in full_url for x in ['/tag/', '/search/', '/category/', '/login', '.pdf', '.jpg', '/video/', '/live/']): continue candidates.add(full_url) # 1b. Sitemap fallback if few candidates found if len(candidates) < 5: logger.info(f"Only {len(candidates)} candidates found, trying sitemap fallback...") candidates |= self._discover_sitemap_urls() logger.info(f"Found {len(candidates)} links total.") # 2. Prioritize hard news over soft news for better bias analysis scored_candidates = [] for url in candidates: score = 0 u = url.lower() # Boost hard news sections if any(x in u for x in ['/news', '/politics', '/world', '/business', '/economy', '/uk-news', '/us-news', '/us-politics', '/global']): score += 10 # Boost hard news keywords in URL slug if any(x in u for x in ['government', 'election', 'war', 'senate', 'congress', 'parliament', 'law', 'court', 'policy', 'minister', 'president', 'military', 'conflict', 'protest']): score += 5 # Demote soft news sections if any(x in u for x in ['/sport', '/sports', '/culture', '/arts', '/travel', '/food', '/style', '/entertainment', '/life', '/lifestyle', '/celebrity', '/recipe', '/wellness', '/fitness', '/music', '/movies', '/tv-shows', '/gaming']): score -= 10 scored_candidates.append((score, url)) # Sort by score descending (hard news first) scored_candidates.sort(key=lambda x: x[0], reverse=True) target_links = [x[1] for x in scored_candidates[:self.max_articles * 2]] top_score = scored_candidates[0][0] if scored_candidates else 0 logger.info(f"Prioritized {len(target_links)} links (top score: {top_score}). Scraping {self.max_articles}...") # 3. Scrape them in parallel articles = [] with ThreadPoolExecutor(max_workers=5) as executor: future_to_url = {executor.submit(self._parse_article, url): url for url in target_links} for future in as_completed(future_to_url): if len(articles) >= self.max_articles: break res = future.result() if res and len(res.text) > 200: # Ensure valid article text (lowered for newsletters) articles.append(res) logger.debug(f"Scraped article: {res.title[:80]} ({res.url})") elapsed = time.time() - t_start logger.info(f"Scraping complete for {self.base_url}: {len(articles)} articles in {elapsed:.1f}s") return articles def _parse_article(self, url: str) -> Optional[Article]: """Parses a single article URL.""" if url in self.visited_urls: return None self.visited_urls.add(url) soup = self.fetch_page(url) if not soup: logger.debug(f"Failed to parse article (no soup): {url}") return None # Extract Title title = soup.title.get_text(strip=True) if soup.title else "" h1 = soup.find('h1') if h1: title = h1.get_text(strip=True) # Extract Text (Heuristic: Find the container with the most paragraphs) best_div = None max_p = 0 # Search common content containers candidates = soup.find_all(['div', 'article', 'section', 'main']) for div in candidates: p_count = len(div.find_all('p', recursive=False)) if p_count > max_p: max_p = p_count best_div = div if best_div and max_p > 3: paragraphs = best_div.find_all('p') else: paragraphs = soup.find_all('p') # Fallback text = "\n\n".join([p.get_text(strip=True) for p in paragraphs if len(p.get_text(strip=True)) > 30]) if len(text) < 200: logger.debug(f"Article too short ({len(text)} chars), skipping: {url}") return None # Check for Sources (External links) sources = [] for a in soup.find_all('a', href=True): if 'http' in a['href'] and self.domain not in a['href']: sources.append(a['href']) # Detect if article is opinion/editorial vs straight news is_opinion = self._detect_opinion_article(url, title, soup) # Try to extract author author = self._extract_author(soup) # Try to extract category category = self._extract_category(url, soup) return Article( url=url, title=title, text=text, author=author, date="Unknown", category=category, has_sources=len(sources) > 0, source_links=sources, is_opinion=is_opinion ) def _detect_opinion_article(self, url: str, title: str, soup: BeautifulSoup) -> bool: """ Detects if an article is opinion/editorial vs straight news. Important for MBFC methodology which separates news reporting from editorial bias. """ # URL indicators opinion_url_patterns = [ '/opinion/', '/opinions/', '/editorial/', '/editorials/', '/op-ed/', '/oped/', '/commentary/', '/perspective/', '/analysis/', '/column/', '/columns/', '/blog/', '/views/', '/viewpoint/', '/contributor/' ] url_lower = url.lower() if any(pattern in url_lower for pattern in opinion_url_patterns): return True # Title indicators title_lower = title.lower() opinion_title_patterns = [ 'opinion:', 'editorial:', 'commentary:', 'analysis:', 'column:', 'op-ed:', 'perspective:', 'letter to', 'my view', 'i think', 'why we should', 'why i' ] if any(pattern in title_lower for pattern in opinion_title_patterns): return True # Meta tag indicators meta_section = soup.find('meta', {'property': 'article:section'}) if meta_section: section = meta_section.get('content', '').lower() if any(x in section for x in ['opinion', 'editorial', 'commentary', 'analysis']): return True # Schema.org indicators schema_type = soup.find('script', {'type': 'application/ld+json'}) if schema_type: try: import json data = json.loads(schema_type.string) if isinstance(data, dict): article_type = data.get('@type', '').lower() if 'opinion' in article_type or 'analysis' in article_type: return True except: pass # CSS class indicators article_elem = soup.find('article') if article_elem: classes = ' '.join(article_elem.get('class', [])) if any(x in classes.lower() for x in ['opinion', 'editorial', 'commentary']): return True return False def _extract_author(self, soup: BeautifulSoup) -> str: """Extracts author name from article.""" # Common author selectors author_selectors = [ ('meta', {'name': 'author'}), ('meta', {'property': 'article:author'}), ('a', {'rel': 'author'}), ('span', {'class': re.compile(r'author', re.I)}), ('p', {'class': re.compile(r'author', re.I)}), ('div', {'class': re.compile(r'byline', re.I)}), ] for tag, attrs in author_selectors: elem = soup.find(tag, attrs) if elem: if tag == 'meta': return elem.get('content', 'Unknown') else: return elem.get_text(strip=True)[:100] # Cap length return "Unknown" def _extract_category(self, url: str, soup: BeautifulSoup) -> Optional[str]: """Extracts article category/section.""" # Try meta tag meta_section = soup.find('meta', {'property': 'article:section'}) if meta_section: return meta_section.get('content') # Try URL path path_parts = url.split('/') if len(path_parts) > 3: potential_category = path_parts[3] if len(potential_category) > 2 and potential_category.isalpha(): return potential_category.title() return None def get_metadata(self) -> SiteMetadata: """ Scans homepage for 'About', 'Contact', 'Terms' to estimate transparency. """ meta = SiteMetadata(domain=self.domain) soup = self.fetch_page(self.base_url) if not soup: return meta # Convert all link text to lowercase for searching links_text = " ".join([a.get_text().lower() for a in soup.find_all('a', href=True)]) footer_text = " ".join([f.get_text().lower() for f in soup.find_all('footer')]) # 1. Check for About Page if any(x in links_text for x in ['about us', 'about the bbc', 'who we are', 'our story']): meta.has_about_page = True # 2. Check for Contact/Location if any(x in links_text for x in ['contact', 'contact us', 'help', 'locations']): meta.location_disclosed = True meta.contact_info = "Found contact link" # 3. Check for Authors/Masthead if any(x in links_text for x in ['meet the team', 'editorial staff', 'authors', 'journalists']): meta.has_author_pages = True # 4. Check for Funding/Ownership (Keywords in footer often indicate this) if any(x in footer_text for x in ['copyright', 'all rights reserved', 'published by', 'funded by']): meta.ownership_disclosed = True # Basic assumption for standard footers meta.funding_disclosed = True return meta