← All notes

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 CTFs

Sample 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