#!/usr/bin/env python3
"""
Blogger script: generate a post about upcoming football matches from a CSV file (NO API REQUIRED).
Fully free, offline, no external dependencies except optional Blogger API libs.
How it works:
1. Create/edit `matches.csv` with upcoming fixtures (copy from BBC, Flashscore, etc.)
2. Run script to generate SEO-friendly HTML.
3. Paste HTML into Blogger or auto-publish.
CSV format (required columns marked *):
league*,datetime_local*,home_team*,away_team,round,venue,city,country,home_badge_url,away_badge_url
Example `matches.csv`:
league,datetime_local,home_team,away_team,round,venue,city,country
EPL,2024-10-20 15:00,Arsenal,Manchester City,10,Emirates Stadium,London,England,https://example.com/arsenal.svg,https://example.com/mancity.svg
La Liga,2024-10-21 20:00,Real Madrid,Barcelona,9,Santiago Bernabeu,Madrid,Spain
Notes:
- datetime_local: ISO format (YYYY-MM-DD HH:MM) in your display timezone (e.g., Europe/London).
- Script filters by --days, --start-hour/--end-hour.
- Badge URLs: Optional SVGs/PNGs from Wikimedia or official sites.
- Run `--generate-sample` for a starter CSV.
Usage:
python football_blogger_script.py --generate-sample > matches.csv
# Edit matches.csv
python football_blogger_script.py --days 7 --tz Europe/London > post.html
# Or publish: see Blogger notes below.
pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib # Optional for --publish
"""
import argparse
import csv
import datetime as dt
import email.utils
import json
import logging
import os
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Dict, Any
from zoneinfo import ZoneInfo
# --------------------
# Configuration dataclass
# --------------------
@dataclass
class Config:
csv_file: str # Path to matches.csv
leagues: List[str] = None # Optional: filter to these leagues only
days_ahead: int = 7 # Filter to next N days
start_hour_local: int = 0 # Filter to local hours (inclusive)
end_hour_local: int = 23 # Filter to local hours (inclusive)
timezone: str = "UTC" # IANA tz for datetime_local and display (e.g., Europe/London)
title: str = "Upcoming Football Matches"
tags: List[str] = None # Blogger labels
blog_id: Optional[str] = None
publish: bool = False
output_mode: str = "print" # "print", "html", "json", "blogger"
blogger_mode: str = "oauth" # "oauth" or "service-account"
generate_sample: bool = False
def __post_init__(self):
if self.tags is None:
self.tags = ["Football", "Fixtures", "Soccer"]
if self.timezone not in ("UTC", "local"):
try:
ZoneInfo(self.timezone)
except Exception:
raise ValueError(f"Invalid timezone '{self.timezone}'. Use IANA name like 'Europe/London'.")
# --------------------
# Badge fallbacks (Wikimedia SVGs)
# --------------------
TEAM_BADGE_FALLBACKS = {
"Liverpool": "https://upload.wikimedia.org/wikipedia/en/0/0c/Liverpool_FC.svg",
"Man City": "https://upload.wikimedia.org/wikipedia/en/e/eb/Manchester_City_FC_badge.svg",
"Arsenal": "https://upload.wikimedia.org/wikipedia/en/5/53/Arsenal_FC.svg",
"Chelsea": "https://upload.wikimedia.org/wikipedia/en/c/cc/Chelsea_FC.svg",
"Man United": "https://upload.wikimedia.org/wikipedia/en/7/7a/Manchester_United_FC.svg",
"Real Madrid": "https://upload.wikimedia.org/wikipedia/en/5/56/Real_Madrid_CF.svg",
"Barcelona": "https://upload.wikimedia.org/wikipedia/en/4/47/FC_Barcelona.svg",
"Bayern Munich": "https://upload.wikimedia.org/wikipedia/en/1/1f/FC_Bayern_M%C3%BCnchen_logo_%282017%29.svg",
# Add more as needed
}
# --------------------
# Utilities
# --------------------
def is_within_local_window(dt_event: dt.datetime, start_hour: int, end_hour: int) -> bool:
hour = dt_event.hour
if start_hour <= end_hour:
return start_hour <= hour <= end_hour
return hour >= start_hour or hour <= end_hour
def format_dt_for_html(dt_event: dt.datetime, tz_str: str) -> str:
tz = ZoneInfo(tz_str)
local_dt = dt_event.astimezone(tz)
return email.utils.format_datetime(local_dt, usetz=True)
def safe_text(s: Optional[str]) -> str:
return (s or "").strip()
def get_badge_url(team: str, provided_url: Optional[str]) -> Optional[str]:
if provided_url:
return provided_url
return TEAM_BADGE_FALLBACKS.get(team)
# --------------------
# Data model
# --------------------
@dataclass
class Match:
league_name: str
home_team: str
away_team: str
timestamp: Optional[dt.datetime] = None
round: Optional[str] = None
venue: Optional[str] = None
city: Optional[str] = None
country: Optional[str] = None
badge_home: Optional[str] = None
badge_away: Optional[str] = None
# --------------------
# CSV loader
# --------------------
def generate_sample_csv() -> str:
"""Generate a sample matches.csv template with examples."""
sample = """# Paste upcoming matches here (edit dates/teams).
# Sources: BBC Sport, ESPN, Flashscore, etc.
# Times in your timezone, e.g., Europe/London.
league,datetime_local,home_team,away_team,round,venue,city,country,home_badge_url,away_badge_url
EPL,2024-10-27 15:00,Arsenal,Liverpool,9,Emirates Stadium,London,England,https://upload.wikimedia.org/wikipedia/en/5/53/Arsenal_FC.svg,https://upload.wikimedia.org/wikipedia/en/0/0c/Liverpool_FC.svg
EPL,2024-10-27 17:30,Man City,Tottenham,9,Etihad Stadium,Manchester,England
La Liga,2024-10-27 20:00,Real Madrid,Barcelona,10,Santiago Bernabeu,Madrid,Spain,https://upload.wikimedia.org/wikipedia/en/5/56/Real_Madrid_CF.svg,https://upload.wikimedia.org/wikipedia/en/4/47/FC_Barcelona.svg
Bundesliga,2024-10-27 16:30,Bayern Munich,Borussia Dortmund,8,Allianz Arena,Munich,Germany
Champions League,2024-10-29 20:00,PSG,Arsenal,4,Parc des Princes,Paris,France
"""
return sample
def get_matches(config: Config) -> List[Match]:
csv_path = Path(config.csv_file)
if not csv_path.exists():
raise FileNotFoundError(f"CSV file not found: {csv_path}. Use --generate-sample > matches.csv")
tz = ZoneInfo(config.timezone)
now = dt.datetime.now(tz)
cutoff = now + dt.timedelta(days=config.days_ahead)
matches: List[Match] = []
with csv_path.open("r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row_num, row in enumerate(reader, start=2):
try:
dt_str = safe_text(row.get("datetime_local"))
if not dt_str:
continue
ts_naive = dt.datetime.fromisoformat(dt_str)
ts = ts_naive.replace(tzinfo=tz)
# Filters
if ts <= now or ts > cutoff:
continue
if not is_within_local_window(ts, config.start_hour_local, config.end_hour_local):
continue
if config.leagues and row.get("league") not in config.leagues:
continue
badge_home = get_badge_url(row["home_team"], row.get("home_badge_url"))
badge_away = get_badge_url(row["away_team"], row.get("away_badge_url"))
m = Match(
league_name=safe_text(row["league"]),
timestamp=ts,
home_team=safe_text(row["home_team"]),
away_team=safe_text(row["away_team"]),
round=safe_text(row.get("round")),
venue=safe_text(row.get("venue")),
city=safe_text(row.get("city")),
country=safe_text(row.get("country")),
badge_home=badge_home,
badge_away=badge_away,
)
matches.append(m)
except (ValueError, KeyError, IndexError) as e:
logging.warning(f"Skipping invalid row {row_num}: {row} ({e})")
# Sort by timestamp, then league
matches.sort(key=lambda m: (m.timestamp or dt.datetime.max, m.league_name))
return matches
# --------------------
# HTML rendering
# --------------------
def render_html_post(matches: List[Match], config: Config) -> str:
tz_name = config.timezone
title = config.title
now_str = dt.datetime.now(ZoneInfo(tz_name)).strftime("%Y-%m-%d %H:%M:%S")
css = """
"""
# Group by league
by_league: Dict[str, List[Match]] = {}
for m in matches:
by_league.setdefault(m.league_name, []).append(m)
league_html = ""
for league, group in sorted(by_league.items()):
matches_html = ""
for m in group:
time_str = format_dt_for_html(m.timestamp, tz_name) if m.timestamp else "TBD"
venue_str = ", ".join(filter(None, [m.venue, m.city, m.country]))
badge_home_html = f'
' if m.badge_home else ""
badge_away_html = f'
' if m.badge_away else ""
matches_html += f"""
"""
league_html += f'{title}
{css}
"""
return html.strip()
# --------------------
# Blogger (optional)
# --------------------
def get_google_auth(mode: str):
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/blogger"]
token_file = os.environ.get("GOOGLE_TOKEN_FILE") or "token.json"
client_secrets = os.environ.get("GOOGLE_CLIENT_SECRETS") or "client_secrets.json"
creds = None
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, SCOPES)
if mode == "service-account":
refresh_token = os.environ.get("GOOGLE_OAUTH_REFRESH_TOKEN")
client_id = os.environ.get("GOOGLE_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
if not all([refresh_token, client_id, client_secret]):
raise SystemExit("Set GOOGLE_OAUTH_REFRESH_TOKEN, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET")
creds = Credentials(
None, refresh_token=refresh_token, token_uri="https://oauth2.googleapis.com/token",
client_id=client_id, client_secret=client_secret, scopes=SCOPES
)
creds.refresh(Request())
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(client_secrets):
raise SystemExit(f"Create {client_secrets} from Google Cloud Console (OAuth Desktop app). Run once to authorize.")
flow = InstalledAppFlow.from_client_secrets_file(client_secrets, SCOPES)
creds = flow.run_local_server(port=0)
with open(token_file, "w") as token:
token.write(creds.to_json())
service = build("blogger", "v3", credentials=creds, cache_discovery=False)
return service
def publish_to_blogger(service, blog_id: str, title: str, content: str, labels: List[str]) -> Dict[str, Any]:
from googleapiclient.http import set_user_agent
body = {"kind": "blogger#post", "blog": {"id": blog_id}, "title": title, "content": content, "labels": labels}
return service.posts().insert(blogId=blog_id, body=body).execute()
# --------------------
# CLI
# --------------------
def parse_args(argv: List[str]) -> Config:
p = argparse.ArgumentParser(description="Generate Blogger post from matches.csv (no API needed).")
p.add_argument("--csv", type=str, default="matches.csv", help="Path to matches.csv")
p.add_argument("--leagues", type=str, help="Filter: comma-separated leagues (e.g., EPL,La Liga)")
p.add_argument("--days", type=int, default=7, help="Next N days (default 7)")
p.add_argument("--start-hour", type=int, default=12, help="Local start hour (0-23)")
p.add_argument("--end-hour", type=int, default=23, help="Local end hour (0-23)")
p.add_argument("--tz", type=str, default="Europe/London", help="Timezone (IANA)")
p.add_argument("--title", type=str, default="Upcoming Football Matches")
p.add_argument("--tags", type=str, default="Football,Fixtures")
p.add_argument("--blog-id", help="Blogger blog ID (for --publish)")
p.add_argument("--publish", action="store_true", help="Publish to Blogger")
p.add_argument("--mode", choices=["print", "html", "json", "blogger"], default="print")
p.add_argument("--blogger-mode", choices=["oauth", "service-account"], default="oauth")
p.add_argument("--generate-sample", action="store_true", help="Print sample matches.csv")
args = p.parse_args(argv)
return Config(
csv_file=args.csv,
generate_sample=args.generate_sample,
leagues=[l.strip() for l in (args.leagues or "").split(",") if l.strip()],
days_ahead=max(1, args.days),
start_hour_local=max(0, min(23, args.start_hour)),
end_hour_local=max(0, min(23, args.end_hour)),
timezone=args.tz,
title=args.title,
tags=[t.strip() for t in args.tags.split(",") if t.strip()],
blog_id=args.blog_id,
publish=args.publish,
output_mode=args.mode if not args.publish else "blogger",
blogger_mode=args.blogger_mode,
)
# --------------------
# Main
# --------------------
def main(argv: List[str]) -> int:
config = parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
if config.generate_sample:
print(generate_sample_csv())
return 0
matches = get_matches(config)
logging.info("Loaded %d matches from %s", len(matches), config.csv_file)
if config.output_mode == "json":
data = [{"league": m.league_name, "home": m.home_team, "away": m.away_team,
"datetime_local": m.timestamp.isoformat() if m.timestamp else None,
"venue": m.venue, "round": m.round} for m in matches]
print(json.dumps({"matches": data, "config": config.__dict__}, indent=2, ensure_ascii=False))
return 0
html = render_html_post(matches, config)
if config.output_mode in ("html", "print"):
if config.output_mode == "html":
out_path = Path(f"fixtures_{dt.datetime.now().strftime('%Y%m%d')}.html")
out_path.write_text(html, "utf-8")
print(f"Saved: {out_path}")
else:
print(html)
return 0
# Blogger
if config.output_mode == "blogger":
if not config.blog_id:
print("Error: --blog-id required", file=sys.stderr)
return 1
service = get_google_auth(config.blogger_mode)
post = publish_to_blogger(service, config.blog_id, config.title, html, config.tags)
print(f"Published: https://www.blogger.com/blog/post/edit?id={post['id']}")
print(f"View: {post['url']}")
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
{badge_home_html}
{m.home_team}
VS
{m.away_team}
{badge_away_html}{time_str}
{league}
{matches_html}
'
if not league_html:
league_html = 'No matches match the filters. Edit matches.csv and try again.
' html = f"""{title}
Filters: Next {config.days_ahead} days, {config.start_hour_local}:00–{config.end_hour_local}:00 {tz_name}. Generated: {now_str}.
{"".join(f'{tag}' for tag in config.tags)}
{league_html}