$ cd ../
$ cat /backups/brain/
0035
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:

1
2
3
4
$ 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[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:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#!/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
$ cd ../