First Version
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: ics2json
|
||||
description: Download an iCalendar (ICS) feed from a URL and output structured JSON with all events. Use this skill whenever the user needs to read, analyze, or integrate events from a public iCal feed such as Teamup or Google Calendar, especially when no API keys are available. Also use as the base for cron jobs that periodically analyze a calendar feed. If the user mentions an .ics file or calendar feed, use this skill.
|
||||
---
|
||||
|
||||
# ics2json
|
||||
|
||||
_Updated: 2026-06-08_
|
||||
|
||||
Downloads an iCalendar (ICS) feed from a URL and outputs structured JSON with all events.
|
||||
|
||||
## When to use
|
||||
|
||||
- Reading and analyzing events from a public iCal feed (Teamup, Google Calendar, etc.)
|
||||
- Integrating an external calendar without API keys
|
||||
- As the base for cron jobs that periodically analyze a feed
|
||||
|
||||
## Script
|
||||
|
||||
`python3 skills/ics2json/ics2json.py <url> [options]`
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--days N` | Only events starting within the next N days |
|
||||
| `--all` | Include past events (default: future or ongoing only) |
|
||||
| `--pretty` | Pretty-print JSON output (default: compact, single-line) |
|
||||
| `--meta` | Only output calendar metadata (name, description, event count). No events returned. |
|
||||
|
||||
### Output JSON format
|
||||
|
||||
```json
|
||||
{
|
||||
"calendar": "Calendar name",
|
||||
"description": "Calendar description",
|
||||
"feed_url": "Feed URL",
|
||||
"fetched_at": "2026-05-20T12:00:00+01:00",
|
||||
"total_events": 5,
|
||||
"events": [
|
||||
{
|
||||
"uid": "TU123456",
|
||||
"title": "Event title",
|
||||
"description": "Description...",
|
||||
"location": "Venue",
|
||||
"where": "Teamup address",
|
||||
"categories": "kink oriented event ⛓️",
|
||||
"event_url": "https://teamup.com/...",
|
||||
"external_url": "https://tickets.example.com",
|
||||
"start": "2026-05-20T19:00:00+01:00",
|
||||
"end": "2026-05-20T23:00:00+01:00",
|
||||
"created": "2026-05-01T10:00:00+00:00",
|
||||
"last_modified": null,
|
||||
"stamp": "2026-05-19T13:18:47+00:00",
|
||||
"attachments": [{"url": "https://...", "type": "image/jpeg", "filename": "flyer.jpg"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Download a Teamup feed, future events only (compact output)
|
||||
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics
|
||||
|
||||
# Metadata only (compact)
|
||||
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics --meta
|
||||
|
||||
# Only the next 30 days (compact)
|
||||
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics --days 30
|
||||
|
||||
# All events (including past), pretty-printed for human reading
|
||||
python3 skills/ics2json/ics2json.py https://ics.teamup.com/feed/ksmt7zqvai72zisjo4/12645979.ics --all --pretty
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download an iCal feed and output events as JSON."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.request import urlopen
|
||||
|
||||
from icalendar import Calendar
|
||||
|
||||
|
||||
def parse_dt(value: Any) -> Optional[str]:
|
||||
"""Parse an iCal date/datetime value and return ISO 8601 string."""
|
||||
if value is None:
|
||||
return None
|
||||
dt = value.dt
|
||||
if isinstance(dt, datetime):
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
# date-only (all-day events)
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def extract_attachments(event: Any) -> List[Dict[str, str]]:
|
||||
"""Extract ATTACH properties as a list of {url, type} dicts."""
|
||||
attachments: List[Dict[str, str]] = []
|
||||
if "ATTACH" in event:
|
||||
# May be a single value or a list
|
||||
items = event["ATTACH"] if isinstance(event["ATTACH"], list) else [event["ATTACH"]]
|
||||
for item in items:
|
||||
attach: Dict[str, str] = {"url": str(item)}
|
||||
params = item.params
|
||||
if "FMTTYPE" in params:
|
||||
attach["type"] = params["FMTTYPE"]
|
||||
if "FILENAME" in params:
|
||||
attach["filename"] = params["FILENAME"]
|
||||
attachments.append(attach)
|
||||
return attachments
|
||||
|
||||
|
||||
def extract_text(component: Any, key: str) -> Optional[str]:
|
||||
"""Extract a text property, decoded from any encoding."""
|
||||
raw = component.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
# vCategory objects have a .cats attribute (list of vText)
|
||||
if hasattr(raw, "cats"):
|
||||
return ", ".join(str(item) for item in raw.cats)
|
||||
# Other list-like properties
|
||||
if isinstance(raw, list):
|
||||
return ", ".join(str(item) for item in raw)
|
||||
return str(raw)
|
||||
|
||||
|
||||
def event_to_dict(event: Any) -> Dict[str, Any]:
|
||||
"""Convert an iCal VEVENT to a flat dict."""
|
||||
return {
|
||||
"uid": extract_text(event, "UID"),
|
||||
"title": extract_text(event, "SUMMARY"),
|
||||
"description": extract_text(event, "DESCRIPTION"),
|
||||
"location": extract_text(event, "LOCATION"),
|
||||
"where": extract_text(event, "X-TEAMUP-WHERE"),
|
||||
"categories": extract_text(event, "CATEGORIES"),
|
||||
"event_url": extract_text(event, "URL"),
|
||||
"external_url": extract_text(event, "X-TEAMUP-EVENT-URL"),
|
||||
"start": parse_dt(event.get("DTSTART")),
|
||||
"end": parse_dt(event.get("DTEND")),
|
||||
"created": parse_dt(event.get("CREATED")),
|
||||
"last_modified": parse_dt(event.get("LAST-MODIFIED")),
|
||||
"stamp": parse_dt(event.get("DTSTAMP")),
|
||||
"attachments": extract_attachments(event),
|
||||
}
|
||||
|
||||
|
||||
def download_feed(url: str) -> Calendar:
|
||||
"""Download and parse an iCal feed."""
|
||||
with urlopen(url) as response:
|
||||
raw = response.read()
|
||||
return Calendar.from_ical(raw)
|
||||
|
||||
|
||||
def _parse_iso(iso: str) -> datetime:
|
||||
"""Parse an ISO 8601 string, making it UTC-aware."""
|
||||
dt = datetime.fromisoformat(iso)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def is_future(event_dict: Dict[str, Any], now: datetime) -> bool:
|
||||
"""Check if an event is in the future (or ongoing)."""
|
||||
end = event_dict.get("end")
|
||||
if end is None:
|
||||
start = event_dict.get("start")
|
||||
if start is None:
|
||||
return True
|
||||
return _parse_iso(start) >= now
|
||||
return _parse_iso(end) >= now
|
||||
|
||||
|
||||
def is_within_days(event_dict: Dict[str, Any], days: int, now: datetime) -> bool:
|
||||
"""Check if an event starts within the next N days."""
|
||||
start = event_dict.get("start")
|
||||
if start is None:
|
||||
return True
|
||||
dt = _parse_iso(start)
|
||||
cutoff = now + timedelta(days=days)
|
||||
return dt >= now and dt <= cutoff
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Download an iCal feed and output events as JSON")
|
||||
parser.add_argument("url", help="URL of the iCal feed (.ics)")
|
||||
parser.add_argument("--days", type=int, default=None, help="Only return events starting within the next N days")
|
||||
parser.add_argument("--all", action="store_true", help="Include past events (default: future only)")
|
||||
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output (default: compact)")
|
||||
parser.add_argument("--meta", action="store_true", help="Only output calendar metadata (no events)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
cal = download_feed(args.url)
|
||||
except Exception as e:
|
||||
print(f"Error downloading feed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Calendar-level metadata
|
||||
cal_name = extract_text(cal, "X-WR-CALNAME") or extract_text(cal, "SUMMARY") or "Unknown"
|
||||
cal_desc = extract_text(cal, "X-WR-CALDESC") or extract_text(cal, "DESCRIPTION") or ""
|
||||
|
||||
# Count total events (all of them, unfiltered)
|
||||
total_all = sum(1 for c in cal.walk() if c.name == "VEVENT")
|
||||
|
||||
output: Dict[str, Any] = {
|
||||
"calendar": cal_name,
|
||||
"description": cal_desc,
|
||||
"feed_url": args.url,
|
||||
"fetched_at": now.isoformat(),
|
||||
"total_events": total_all,
|
||||
}
|
||||
|
||||
if args.meta:
|
||||
if args.pretty:
|
||||
print(json.dumps(output, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(json.dumps(output, ensure_ascii=False))
|
||||
return
|
||||
|
||||
events: List[Dict[str, Any]] = []
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
ev = event_to_dict(component)
|
||||
|
||||
# Apply filters
|
||||
if not args.all and not is_future(ev, now):
|
||||
continue
|
||||
if args.days is not None and not is_within_days(ev, args.days, now):
|
||||
continue
|
||||
|
||||
events.append(ev)
|
||||
|
||||
# Sort by start date (ascending, None at the end)
|
||||
events.sort(key=lambda e: e.get("start") or "9999")
|
||||
|
||||
output["total_events"] = len(events)
|
||||
output["events"] = events
|
||||
|
||||
if args.pretty:
|
||||
print(json.dumps(output, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(json.dumps(output, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user