302 lines
11 KiB
Python
302 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""Scrape Gladbach-focused football news from German sources."""
|
||
|
||
import requests
|
||
from bs4 import BeautifulSoup
|
||
import json
|
||
import time
|
||
import re
|
||
|
||
HEADERS = {
|
||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||
}
|
||
|
||
GLADBACH_KW = [
|
||
'gladbach', 'borussia', 'mönchengladbach', 'fohlen', 'fohlenelf',
|
||
'borussia park', 'niederrhein', 'borussen',
|
||
'elvedi', 'plea', 'honorat', 'neuhaus', 'weigl', 'koné',
|
||
'scally', 'oultara', 'friedrich', 'itakura', 'ngoumou',
|
||
'cvančara', 'civancara', 'ranos', 'hack',
|
||
'reitz', 'netz', 'wöber', 'woeber', 'kleindienst',
|
||
'seoane', 'virkus',
|
||
]
|
||
|
||
def fetch_url(url, retries=2):
|
||
for attempt in range(retries + 1):
|
||
try:
|
||
resp = requests.get(url, headers=HEADERS, timeout=20)
|
||
resp.raise_for_status()
|
||
resp.encoding = resp.apparent_encoding or 'utf-8'
|
||
return resp
|
||
except Exception as e:
|
||
if attempt < retries:
|
||
time.sleep(2)
|
||
else:
|
||
print(f" FAILED: {url} — {e}")
|
||
return None
|
||
|
||
def clean_text(text):
|
||
return re.sub(r'\s+', ' ', text).strip()
|
||
|
||
def is_recent_date(date_str):
|
||
patterns = [
|
||
r'01\.06\.2026', r'02\.06\.2026',
|
||
r'1\.\s*Juni\s*2026', r'2\.\s*Juni\s*2026',
|
||
r'01\.06\.26', r'02\.06\.26',
|
||
r'2026-06-01', r'2026-06-02',
|
||
r'vor \d+ Stunden', r'vor \d+ Minuten',
|
||
]
|
||
for pat in patterns:
|
||
if re.search(pat, date_str, re.IGNORECASE):
|
||
return True
|
||
return False
|
||
|
||
def has_gladbach_keyword(text, min_hits=1):
|
||
text_lower = text.lower()
|
||
hits = sum(1 for kw in GLADBACH_KW if kw in text_lower)
|
||
return hits >= min_hits
|
||
|
||
def extract_article_text(soup):
|
||
selectors = [
|
||
'article', '.article-content', '.article-body', '.post-content',
|
||
'.entry-content', '.content', '.story-body', '.article__body',
|
||
'.article-text', '.news-text', 'main', '.main-content',
|
||
'[itemprop="articleBody"]', '.article__content', '.post__content',
|
||
'.single-content', '#article-content',
|
||
]
|
||
for sel in selectors:
|
||
elem = soup.select_one(sel)
|
||
if elem:
|
||
for unwanted in elem.find_all(['script', 'style', 'nav', 'aside', 'footer', 'header', 'iframe', 'form']):
|
||
unwanted.decompose()
|
||
text = elem.get_text(separator=' ', strip=True)
|
||
text = clean_text(text)
|
||
if len(text) > 200:
|
||
return text
|
||
paragraphs = soup.find_all('p')
|
||
texts = [clean_text(p.get_text()) for p in paragraphs if len(p.get_text().strip()) > 50]
|
||
if texts:
|
||
return ' '.join(texts)
|
||
body = soup.find('body')
|
||
if body:
|
||
for unwanted in body.find_all(['script', 'style', 'nav', 'footer', 'header', 'iframe']):
|
||
unwanted.decompose()
|
||
return clean_text(body.get_text(separator=' ', strip=True))
|
||
return ""
|
||
|
||
def extract_date(soup):
|
||
date_patterns = [
|
||
('meta[property="article:published_time"]', 'content'),
|
||
('meta[name="date"]', 'content'),
|
||
('meta[name="pubdate"]', 'content'),
|
||
('time', 'datetime'),
|
||
('time[datetime]', 'datetime'),
|
||
]
|
||
for selector, attr in date_patterns:
|
||
elem = soup.select_one(selector)
|
||
if elem:
|
||
val = elem.get(attr, '') if attr else elem.get_text(strip=True)
|
||
if val and len(val) > 5:
|
||
return val
|
||
text = soup.get_text()
|
||
m = re.search(r'(\d{1,2}\.\s*\d{1,2}\.\s*(?:20)?2[456])', text)
|
||
return m.group(1) if m else ""
|
||
|
||
def generic_scrape(name, base_urls, url_filter=None, title_cleanup=None, gladbach_check=True):
|
||
"""Generic scraper for a news site."""
|
||
print(f"\n{'='*60}\nSCRAPING: {name}\n{'='*60}")
|
||
results = []
|
||
|
||
for base_url in base_urls:
|
||
print(f" Trying: {base_url}")
|
||
resp = fetch_url(base_url)
|
||
if not resp:
|
||
continue
|
||
|
||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||
links = soup.find_all('a', href=True)
|
||
article_urls = set()
|
||
|
||
for link in links:
|
||
href = link['href']
|
||
if not href or href.startswith('#') or href.startswith('javascript:') or href.startswith('mailto:'):
|
||
continue
|
||
if href.endswith(('.jpg', '.png', '.gif', '.pdf', '.css', '.js', '.ico')):
|
||
continue
|
||
|
||
# Build full URL
|
||
if href.startswith('http'):
|
||
full_url = href
|
||
elif href.startswith('//'):
|
||
full_url = 'https:' + href
|
||
elif href.startswith('/'):
|
||
# Extract domain from base_url
|
||
domain = re.match(r'https?://[^/]+', base_url)
|
||
if domain:
|
||
full_url = domain.group(0) + href
|
||
else:
|
||
continue
|
||
else:
|
||
continue
|
||
|
||
if full_url in article_urls:
|
||
continue
|
||
|
||
# Apply filter if provided
|
||
if url_filter:
|
||
if url_filter(full_url):
|
||
article_urls.add(full_url)
|
||
else:
|
||
article_urls.add(full_url)
|
||
|
||
print(f" Found {len(article_urls)} potential article URLs (sampling first 30)")
|
||
|
||
for art_url in list(article_urls)[:30]:
|
||
print(f" Fetching: {art_url}")
|
||
art_resp = fetch_url(art_url)
|
||
if not art_resp:
|
||
continue
|
||
|
||
art_soup = BeautifulSoup(art_resp.text, 'html.parser')
|
||
|
||
# Extract title
|
||
title = None
|
||
title_elem = art_soup.find('title')
|
||
if title_elem:
|
||
title = clean_text(title_elem.get_text())
|
||
if title_cleanup:
|
||
title = title_cleanup(title)
|
||
if not title or len(title) < 15:
|
||
h1 = art_soup.find('h1')
|
||
if h1:
|
||
title = clean_text(h1.get_text())
|
||
if not title or len(title) < 15:
|
||
continue
|
||
|
||
date_str = extract_date(art_soup)
|
||
full_text = extract_article_text(art_soup)
|
||
|
||
if not full_text or len(full_text) < 100:
|
||
print(f" Skipping (no usable text)")
|
||
continue
|
||
|
||
if date_str and not is_recent_date(date_str):
|
||
print(f" Skipping (date: {date_str})")
|
||
continue
|
||
|
||
if gladbach_check:
|
||
combined = (title + ' ' + full_text).lower()
|
||
if not has_gladbach_keyword(combined, min_hits=1):
|
||
print(f" Not Gladbach-related, skipping")
|
||
continue
|
||
|
||
print(f" ✓ FOUND: {title[:80]}")
|
||
results.append({
|
||
'source': name,
|
||
'title': title,
|
||
'url': art_url,
|
||
'date': date_str or 'Kein Datum gefunden',
|
||
'full_text': full_text[:3000],
|
||
})
|
||
|
||
if len(results) >= 2:
|
||
break
|
||
|
||
if results:
|
||
break
|
||
|
||
return results
|
||
|
||
def main():
|
||
all_results = []
|
||
|
||
# 1. torfabrik.de
|
||
all_results.extend(generic_scrape(
|
||
'torfabrik.de',
|
||
['https://www.torfabrik.de/', 'https://www.torfabrik.de/borussia-moenchengladbach/'],
|
||
url_filter=lambda u: 'torfabrik.de' in u,
|
||
title_cleanup=lambda t: re.sub(r'\s*[-–|]\s*[Tt]orfabrik.*$', '', t).strip(),
|
||
))
|
||
|
||
# 2. fohlen-hautnah.de
|
||
all_results.extend(generic_scrape(
|
||
'fohlen-hautnah.de',
|
||
['https://www.fohlen-hautnah.de/'],
|
||
url_filter=lambda u: 'fohlen-hautnah.de' in u,
|
||
title_cleanup=lambda t: re.sub(r'\s*[-–|]\s*[Ff]ohlen[-\s]*[Hh]autnah.*$', '', t).strip(),
|
||
gladbach_check=False, # All articles are Gladbach-related
|
||
))
|
||
|
||
# 3. gladbachlive.de
|
||
all_results.extend(generic_scrape(
|
||
'gladbachlive.de',
|
||
['https://www.gladbachlive.de/', 'https://www.gladbachlive.de/news/'],
|
||
url_filter=lambda u: 'gladbachlive.de' in u,
|
||
title_cleanup=lambda t: re.sub(r'\s*[-–|]\s*[Gg]ladbach[Ll]ive.*$', '', t).strip(),
|
||
))
|
||
|
||
# 4. kicker.de
|
||
all_results.extend(generic_scrape(
|
||
'kicker.de',
|
||
['https://www.kicker.de/borussia-moenchengladbach/news', 'https://www.kicker.de/bundesliga/aktuell'],
|
||
url_filter=lambda u: 'kicker.de' in u and any(kw in u.lower() for kw in ['gladbach', 'borussia-mg', 'bundesliga', 'news', 'transfer']),
|
||
title_cleanup=lambda t: re.sub(r'\s*[-–|]\s*[Kk]icker.*$', '', t).strip(),
|
||
))
|
||
|
||
# 5. sportschau.de (general Bundesliga, no strict Gladbach filter)
|
||
all_results.extend(generic_scrape(
|
||
'sportschau.de',
|
||
['https://www.sportschau.de/fussball/bundesliga/'],
|
||
url_filter=lambda u: 'sportschau.de' in u and '/fussball/' in u,
|
||
title_cleanup=lambda t: re.sub(r'\s*[-–|]\s*[Ss]portschau.*$', '', t).strip(),
|
||
gladbach_check=False,
|
||
))
|
||
|
||
# 6. fussballtransfers.com
|
||
all_results.extend(generic_scrape(
|
||
'fussballtransfers.com',
|
||
['https://www.fussballtransfers.com/bundesliga', 'https://www.fussballtransfers.com/'],
|
||
url_filter=lambda u: 'fussballtransfers.com' in u,
|
||
title_cleanup=lambda t: re.sub(r'\s*[-–|]\s*[Ff]ussball\s*[Tt]ransfers.*$', '', t).strip(),
|
||
))
|
||
|
||
# Deduplicate by URL
|
||
seen_urls = set()
|
||
deduped = []
|
||
for r in all_results:
|
||
if r['url'] not in seen_urls:
|
||
seen_urls.add(r['url'])
|
||
deduped.append(r)
|
||
|
||
# Prioritize: Gladbach articles first (with 2+ keyword hits), then the rest
|
||
gladbach_heavy = [r for r in deduped if has_gladbach_keyword(r['title'] + ' ' + r['full_text'], min_hits=2)]
|
||
gladbach_light = [r for r in deduped if r not in gladbach_heavy and has_gladbach_keyword(r['title'] + ' ' + r['full_text'], min_hits=1)]
|
||
other = [r for r in deduped if r not in gladbach_heavy and r not in gladbach_light]
|
||
|
||
# Take 5: prefer heavy Gladbach, then light Gladbach, then other
|
||
final = gladbach_heavy + gladbach_light + other
|
||
final = final[:5]
|
||
|
||
print("\n\n" + "=" * 60)
|
||
print(f"FINAL RESULTS: {len(final)} articles")
|
||
print("=" * 60)
|
||
|
||
for i, r in enumerate(final, 1):
|
||
print(f"\n--- Artikel {i} ---")
|
||
print(f"Quelle: {r['source']}")
|
||
print(f"Titel: {r['title']}")
|
||
print(f"Datum: {r['date']}")
|
||
print(f"URL: {r['url']}")
|
||
print(f"Textlänge: {len(r['full_text'])} Zeichen")
|
||
|
||
with open('/home/hermes/workspace/gladbach_news.json', 'w', encoding='utf-8') as f:
|
||
json.dump(final, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f"\nResults saved to /home/hermes/workspace/gladbach_news.json")
|
||
return final
|
||
|
||
if __name__ == '__main__':
|
||
main()
|