diff --git a/calendar/calander.py b/calendar/calander.py new file mode 100644 index 000000000..82688760a --- /dev/null +++ b/calendar/calander.py @@ -0,0 +1,140 @@ +import json +from datetime import datetime + +INPUT_FILE = "events.json" +OUTPUT_FILE = "calendar.html" +MAX_EVENTS = 5 + + +def load_events(): + with open(INPUT_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def format_event_html(event): + title = event.get("title", "Untitled Event") + date = event.get("date", "") + start_time = event.get("start_time", "") + end_time = event.get("end_time", "") + location = event.get("location", "No location listed") + description = event.get("description", "") + link = event.get("event_link", "") + + time_text = start_time + if end_time: + time_text += f" - {end_time}" + + if len(description) > 180: + description = description[:177] + "..." + + link_html = "" + if link: + link_html = f"

More Info

" + + return f""" +
+

{title}

+

Date: {date}

+

Time: {time_text}

+

Location: {location}

+

{description}

+ {link_html} +
+ """ + + +def build_html(data): + title = data.get("title", "Upcoming Events") + updated_at = data.get("updated_at", "") + events = data.get("events", [])[:MAX_EVENTS] + + events_html = "".join(format_event_html(event) for event in events) + + html = f""" + + + + + + {title} + + + +
+

{title}

+
Last updated: {updated_at}
+ {events_html} +
+ + +""" + return html + + +def main(): + data = load_events() + html = build_html(data) + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + f.write(html) + + print(f"Created {OUTPUT_FILE}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/calendar/calender_display.py b/calendar/calender_display.py new file mode 100644 index 000000000..5404f6d2d --- /dev/null +++ b/calendar/calender_display.py @@ -0,0 +1,356 @@ +import requests +import json +import re +import time +from datetime import datetime, timedelta + +FEED_URL = "https://events.rpi.edu/feeder/main/eventsFeed.do?f=y&sort=dtstart.utc:asc&fexpr=(categories.href!=%22/public/.bedework/categories/Ongoing%22)%20and%20(entity_type=%22event%22%20or%20entity_type=%22todo%22)&skinName=list-json&setappvar=objName(bwObject)&count=10" +OUTPUT_HTML = "calendar.html" +REFRESH_SECONDS = 1800 # 30 minutes + + +def extract_bwobject(text): + match = re.search(r'var\s+bwObject\s*=\s*(\{.*\})\s*$', text, re.DOTALL) + if not match: + raise ValueError("Could not find bwObject in feed response.") + return match.group(1) + + +def fetch_events(): + response = requests.get(FEED_URL, timeout=20) + response.raise_for_status() + + raw_text = response.text + bwobject_text = extract_bwobject(raw_text) + bwobject = json.loads(bwobject_text) + + return bwobject.get("bwEventList", {}).get("events", []) + + +def parse_event_datetime(date_str, time_str): + if not date_str or not time_str: + return None + + formats = [ + "%B %d, %Y %I:%M %p", + "%b %d, %Y %I:%M %p", + ] + + combined = f"{date_str} {time_str}".strip() + for fmt in formats: + try: + return datetime.strptime(combined, fmt) + except ValueError: + continue + return None + + +def clean_text(text, max_len=None): + if not text: + return "" + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"\s+", " ", text).strip() + if max_len and len(text) > max_len: + return text[: max_len - 3] + "..." + return text + + +def normalize_event(event): + start = event.get("start", {}) + end = event.get("end", {}) + location = event.get("location", {}) + + title = clean_text(event.get("summary", "Untitled Event")) + description = clean_text(event.get("description", ""), 180) + location_text = clean_text(location.get("address", "Location TBA")) + event_link = event.get("eventlink", "") + + start_date = start.get("longdate", "") + start_time = start.get("time", "") + end_date = end.get("longdate", start_date) + end_time = end.get("time", "") + + start_dt = parse_event_datetime(start_date, start_time) + end_dt = parse_event_datetime(end_date, end_time) + + all_day = str(start.get("allday", "false")).lower() == "true" + + return { + "title": title, + "description": description, + "location": location_text, + "event_link": event_link, + "start_date": start_date, + "start_time": start_time, + "end_date": end_date, + "end_time": end_time, + "start_dt": start_dt, + "end_dt": end_dt, + "all_day": all_day, + "formatted_date": clean_text(event.get("formattedDate", "")), + } + + +def choose_events(events): + now = datetime.now() + normalized = [normalize_event(e) for e in events] + + valid = [e for e in normalized if e["start_dt"] is not None] + valid.sort(key=lambda e: e["start_dt"]) + + current_event = None + upcoming = [] + + for event in valid: + start_dt = event["start_dt"] + end_dt = event["end_dt"] + + # If end time is missing, assume 2 hours after start + if end_dt is None and start_dt is not None: + end_dt = start_dt + timedelta(hours=2) + event["end_dt"] = end_dt + + if start_dt <= now <= end_dt: + current_event = event + elif start_dt > now: + upcoming.append(event) + + if current_event: + return "Happening Now", current_event, upcoming[:2] + + if upcoming: + return "Coming Up Next", upcoming[0], upcoming[1:3] + + return "No Upcoming Events", None, [] + + +def format_main_event(event, status_label): + if not event: + return f""" +
+
{status_label}
+

No upcoming events found

+

Please check back later.

+
+ """ + + time_line = "All Day" if event["all_day"] else f'{event["start_time"]} - {event["end_time"]}'.strip(" -") + description_html = f"

{event['description']}

" if event["description"] else "" + + return f""" +
+
{status_label}
+

{event["title"]}

+

Date: {event["start_date"]}

+

Time: {time_line}

+

Location: {event["location"]}

+ {description_html} +
+ """ + + +def format_sidebar_events(events): + if not events: + return "

No additional upcoming events.

" + + html_parts = ["

Also Coming Up

"] + for event in events: + time_line = "All Day" if event["all_day"] else event["start_time"] + html_parts.append(f""" +
+
{event["title"]}
+
{event["start_date"]}
+
{time_line}
+
{event["location"]}
+
+ """) + html_parts.append("
") + return "".join(html_parts) + + +def build_html(status_label, main_event, sidebar_events): + updated_at = datetime.now().strftime("%m/%d/%Y %I:%M %p") + main_html = format_main_event(main_event, status_label) + sidebar_html = format_sidebar_events(sidebar_events) + + html = f""" + + + + + + RPI Events Display + + + +
+
+
+
+

RPI Events

+

Current and upcoming campus events

+
+
+ +
+ {main_html} + {sidebar_html} +
+
+ + +
+ + +""" + return html + + +def write_html(html): + with open(OUTPUT_HTML, "w", encoding="utf-8") as f: + f.write(html) + + +def update_display(): + events = fetch_events() + status_label, main_event, sidebar_events = choose_events(events) + html = build_html(status_label, main_event, sidebar_events) + write_html(html) + print(f"Updated {OUTPUT_HTML} at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + +def main(): + while True: + try: + update_display() + except Exception as e: + print("Error updating display:", e) + + time.sleep(REFRESH_SECONDS) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/calendar/fetch_events.py b/calendar/fetch_events.py new file mode 100644 index 000000000..de531fbd8 --- /dev/null +++ b/calendar/fetch_events.py @@ -0,0 +1,63 @@ +import requests +import json +import re +from datetime import datetime + +FEED_URL = "https://events.rpi.edu/feeder/main/eventsFeed.do?f=y&sort=dtstart.utc:asc&fexpr=(categories.href!=%22/public/.bedework/categories/Ongoing%22)%20and%20(entity_type=%22event%22%20or%20entity_type=%22todo%22)&skinName=list-json&setappvar=objName(bwObject)&count=10" +OUTPUT_FILE = "events.json" + + +def extract_bwobject(text): + """ + Extract the JavaScript object from: + var bwObject = {...} + """ + match = re.search(r'var\s+bwObject\s*=\s*(\{.*\})\s*$', text, re.DOTALL) + if not match: + raise ValueError("Could not find bwObject in feed response.") + return match.group(1) + + +def clean_event(event): + start = event.get("start", {}) + end = event.get("end", {}) + location = event.get("location", {}) + + return { + "title": event.get("summary", ""), + "formatted_date": event.get("formattedDate", ""), + "date": start.get("longdate", ""), + "start_time": start.get("time", ""), + "end_time": end.get("time", ""), + "all_day": str(start.get("allday", "false")).lower() == "true", + "location": location.get("address", ""), + "description": event.get("description", ""), + "event_link": event.get("eventlink", ""), + "categories": event.get("categories", []) + } + + +def main(): + response = requests.get(FEED_URL, timeout=15) + response.raise_for_status() + + raw_text = response.text + bwobject_text = extract_bwobject(raw_text) + bwobject = json.loads(bwobject_text) + + raw_events = bwobject.get("bwEventList", {}).get("events", []) + + cleaned = { + "title": "Upcoming Events", + "updated_at": datetime.now().isoformat(timespec="seconds"), + "events": [clean_event(event) for event in raw_events] + } + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + json.dump(cleaned, f, indent=2, ensure_ascii=False) + + print(f"Saved {len(cleaned['events'])} events to {OUTPUT_FILE}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/feeds/mlb-feed/__pycache__/main.cpython-314.pyc b/feeds/mlb-feed/__pycache__/main.cpython-314.pyc new file mode 100644 index 000000000..ef0a03575 Binary files /dev/null and b/feeds/mlb-feed/__pycache__/main.cpython-314.pyc differ diff --git a/feeds/mlb-feed/concerto-mlb.service.txt b/feeds/mlb-feed/concerto-mlb.service.txt new file mode 100644 index 000000000..f4662cd7f --- /dev/null +++ b/feeds/mlb-feed/concerto-mlb.service.txt @@ -0,0 +1,13 @@ +[Unit] +Description=Concerto MLB Feed +After=network.target + +[Service] +Type=simple +WorkingDirectory=/path/to/python-mlb-feed +ExecStart=/path/to/python-mlb-feed/start.sh +Restart=always +User=YOUR_USERNAME + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/feeds/mlb-feed/main.py b/feeds/mlb-feed/main.py new file mode 100644 index 000000000..520dcbae7 --- /dev/null +++ b/feeds/mlb-feed/main.py @@ -0,0 +1,91 @@ +from typing import Dict, List +from fastapi import FastAPI +import requests +import datetime + +app = FastAPI() + + +MLB_SCHEDULE_URL = "https://statsapi.mlb.com/api/v1/schedule" + + +def format_game_line(game: dict) -> str: + teams = game.get("teams", {}) + away = teams.get("away", {}) + home = teams.get("home", {}) + + away_team = away.get("team", {}).get("name", "Away") + home_team = home.get("team", {}).get("name", "Home") + + away_score = away.get("score") + home_score = home.get("score") + + status = game.get("status", {}) + detailed_state = status.get("detailedState", "Scheduled") + abstract_state = status.get("abstractGameState", "Preview") + + game_datetime = game.get("gameDate") + display_time = "TBD" + + if game_datetime: + try: + dt = datetime.datetime.fromisoformat(game_datetime.replace("Z", "+00:00")) + display_time = dt.astimezone().strftime("%I:%M %p").lstrip("0") + except ValueError: + pass + + if abstract_state == "Final": + return f"FINAL: {away_team} {away_score}, {home_team} {home_score}" + elif abstract_state == "Live": + return f"LIVE: {away_team} {away_score}, {home_team} {home_score} ({detailed_state})" + else: + return f"{display_time}: {away_team} at {home_team}" + + +def get_mlb_games() -> List[str]: + today = datetime.datetime.now().strftime("%Y-%m-%d") + + response = requests.get( + MLB_SCHEDULE_URL, + params={ + "sportId": 1, + "date": today, + "hydrate": "linescore,team,flags", + }, + timeout=20, + ) + + data = response.json() + dates = data.get("dates", []) + if not dates: + return ["No MLB games scheduled today."] + + games = dates[0].get("games", []) + if not games: + return ["No MLB games scheduled today."] + + lines = [format_game_line(game) for game in games[:12]] + return lines + + +@app.get("/mlb.json") +def read_mlb_scores() -> List[Dict[str, str]] | Dict[str, str]: + now = datetime.datetime.now() + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = now.replace(hour=23, minute=59, second=59, microsecond=999999) + + try: + game_lines = get_mlb_games() + except Exception as e: + return {"Error": "Response Failed", "info": str(e)} + + html = " | ".join(game_lines) + + return [{ + "name": "MLB Scores", + "type": "RichText", + "render_as": "html", + "start_time": start.strftime("%Y-%m-%dT%H:%M:%SZ"), + "end_time": end.strftime("%Y-%m-%dT%H:%M:%SZ"), + "text": html + }] \ No newline at end of file diff --git a/feeds/mlb-feed/requirements.txt b/feeds/mlb-feed/requirements.txt new file mode 100644 index 000000000..b07ade6ba --- /dev/null +++ b/feeds/mlb-feed/requirements.txt @@ -0,0 +1,2 @@ +fastapi[standard] +requests \ No newline at end of file diff --git a/feeds/mlb-feed/start.sh b/feeds/mlb-feed/start.sh new file mode 100644 index 000000000..3d35bb6b9 --- /dev/null +++ b/feeds/mlb-feed/start.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +. .venv/bin/activate +fastapi run --host 0.0.0.0 --port 43679 +deactivate \ No newline at end of file