CTFTime view CTFs in the CLI
Print the next CTFTime CTFs from the command line, include important fields such as start/end date (local time), weight, duration and links.
Usage:
$ ctf-list # Show all available CTFs
$ ctf-list 01-11-2025 30-11-2025 # Show CTFs in range
$ ctf-list now 05-05-2025 # Show CTFs in range
$ ctf-list +5 # Show the next N CTFsSample output:
[ecomaikgolf@laptop ~/ctf-list/]$ ./ctf-list +3
[100.00] PlaidCTF 2025
| - Start: Friday 04/04/2025 23:00:00 | in 4 days
| - End: Sunday 06/04/2025 23:00:00 | for 48h
| - Duration: 48h
| - Weight: 100.0
| - Location: On-line
| - CTFTime: https://ctftime.org/event/2508
| - URL: https://plaidctf.com/
[ 24.25] squ1rrel CTF 2025
| - Start: Saturday 05/04/2025 01:00:00 | in 4 days
| - End: Sunday 06/04/2025 19:00:00 | for 42h
| - Duration: 42h
| - Weight: 24.25
| - Location: On-line
| - CTFTime: https://ctftime.org/event/2708
| - URL: https://ctf.squ1rrel.dev/
[ 0.00] Breach CTF 2025
| - Start: Saturday 05/04/2025 04:30:00 | in 4 days
| - End: Sunday 06/04/2025 04:30:00 | for 24h
| - Duration: 24h
| - Weight: 0.0
| - Location: On-line
| - CTFTime: https://ctftime.org/event/2671
| - URL: https://www.breachers.in/
Source code:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "requests"
# ]
# ///
import requests
from datetime import datetime
from zoneinfo import ZoneInfo
import sys
import os
def find_tag(st, t):
ln = len(f"<{t}>")
s = ctf.find(f"<{t}>")
e = ctf.find(f"</{t}>")
return st[s + ln:e]
#
# Timezone Handling
#
timez = os.getenv('TZ', 'Europe/Vienna')
#
# Argument parsing
#
start = None
end = None
total = None
if len(sys.argv) >= 2:
if sys.argv[1] == "now":
start = datetime.now().replace(tzinfo=ZoneInfo('UTC'))
elif sys.argv[1][0] == "+":
total = int(sys.argv[1][1:])
if total is None:
print(f"[!] {sys.argv[1]} couldn't be parsed as integer")
exit(1)
start = None
else:
start = datetime.strptime(sys.argv[1], "%d-%m-%Y").replace(tzinfo=ZoneInfo('UTC'))
if start is None:
print(f"[!] {sys.argv[1]} couldn't be parsed by datetime")
exit(1)
if len(sys.argv) >= 3:
end = datetime.strptime(sys.argv[2], "%d-%m-%Y").replace(tzinfo=ZoneInfo('UTC'))
if end is None:
print(f"[!] {sys.argv[2]} couldn't be parsed by datetime")
exit(1)
if total is not None:
end = None
#
# RSS Request
#
n = datetime.now().replace(tzinfo=ZoneInfo('UTC')).astimezone(ZoneInfo(timez))
response = requests.get('https://ctftime.org/event/list/upcoming/rss/')
if response.status_code != 200:
print("[!] Error fetching RSS feed")
exit(1)
raw = response.text
raw = raw.split("<item>")[1:]
#
# CTF Parsing
#
i = 0
for ctf in raw:
title = find_tag(ctf, "title")
guid = find_tag(ctf, "guid")
url = find_tag(ctf, "url")
w = float(find_tag(ctf, "weight"))
sd = find_tag(ctf, "start_date")
ed = find_tag(ctf, "finish_date")
desc = find_tag(ctf, "description")
loc = None
if "On-line" in desc:
loc = "On-line"
elif "On-site" in desc:
loc = "On-site"
else:
loc = "Unknown"
sd = datetime.fromisoformat(sd).replace(tzinfo=ZoneInfo('UTC'))
ed = datetime.fromisoformat(ed).replace(tzinfo=ZoneInfo('UTC'))
if start is not None and sd < start:
continue
if end is not None and sd > end:
continue
sd = sd.astimezone(ZoneInfo(timez))
ed = ed.astimezone(ZoneInfo(timez))
ft_sd = sd.strftime("%d/%m/%Y %H:%M:%S")
ft_ed = ed.strftime("%d/%m/%Y %H:%M:%S")
fd_sd = sd.strftime("%A")
fd_ed = ed.strftime("%A")
rem = round((sd - n).total_seconds() / (24 * 60 * 60))
ft_r = ""
if (int(rem/7)) > 0:
ft_r += f"{int(rem/7)} week(s)"
if (rem%7) > 0:
ft_r += f" and {rem%7} day(s)"
elif (rem%7) > 0:
ft_r += f"{rem%7} days"
else:
ft_r += "today"
dur = round((ed - sd).total_seconds() / (60 * 60))
print(f"""
[{w:6.2f}] {title}
| - Start: {fd_sd:9} {ft_sd} | in {ft_r}
| - End: {fd_ed:9} {ft_ed} | for {dur}h
| - Duration: {dur}h
| - Weight: {w}
| - Location: {loc}
| - CTFTime: {guid}
| - URL: {url}
""")
i += 1
if total is not None and i >= total:
break
# vim: filetype=python