2026-03-28
Here's my capsule's tinylog aggregator: a Python cgi script using gtl
the finished tinylog aggregator page
see also this post about the script i use to generate my tinylog
My capsule is served by gmCapsule; presumably these examples will work fine with other cgi-capable servers too.
If you want to run either of these scripts, you'll also need to download GTL (thanks to @bacardi55 & @fab!):
https://codeberg.org/fab/gtl
I'm still very much a coding novice; my first approach was to re-use some functionality from previous gemini experiments; this script checks the client identity hash against a hard-coded value (i.e., check if it's me visiting the page), and if it's me visiting, runs gtl to request data from the subscribed list of tinylogs, and prints the output directly to the browser.
Other visitors would be served a file that only gets updated every few hours via a cron job. This served as a kind of rate-limit for everyone but me.
That crontab entry looked roughly like so:
0 0,4,8,12,16,20 * * * /path/to/gtl --mode gemini --limit 50 > /path/to/gtl_output.gmi
EDIT, 2026-03-29:
Thanks to some reader feedback, here's an improved version of the cron entry; instead of running gtl right on the hour, roll a random number between 1 and 60 (I ran 'random.randint(1,60)' in python), and instead of listing all six 4-hour intervals, use "*/4" instead.
44 */4 * * * /path/to/gtl --mode gemini --limit 50 > /path/to/gtl_output.gmi
The script is pretty hacky, mostly I just fumbled around with it until it more or less worked.
Installation:
install gtl (link above)
copy script into your server's cgi folder
fill in the variables at the top
add a cron entry and run the cron job at least once.
Example 1, the cobbled-together version:
#!/usr/bin/env python3
import subprocess
import os
########################################## fill in these variables ###############
# number of tinylog entries to display
out_limit = 50
username = "<your username>"
# to get your certificate/identity hash in Lagrange, right-click on the identity
# and select 'copy SHA-256 fingerprint'. Then paste it into the following line:
allowedCert = "<64-character client identity hash>"
path_to_gtl = "</path/to/gtl>"
path_to_saved_file = "</path/to/saved-file>"
##################################################################################
print ("20 text/gemini; charset=utf-8\r\n") # standard first response to browser
print ("# TinyLogRoll")
print ("=> index.gmi home")
print ("generated by gtl:")
print ("=> https://codeberg.org/fab/gtl/src/branch/main/docs/mode-gemini.md")
# get client certificate hash
TLS_CLIENT_HASH = os.getenv('TLS_CLIENT_HASH')
if (TLS_CLIENT_HASH == allowedCert):
print(f"hi, {username}!")
# run gtl; stdout gets printed directly to the browser
subprocess.run([f"{path_to_gtl}", "--mode", "gemini", "--limit", f"{out_limit}"])
else:
print("(to avoid the need for any extra rate-limiting, content on this page is refreshed every few hours, unless the page is loaded by the authorized user, in which case the content is retrieved on page load)")
with open(path_to_saved_file, 'r') as f:
output = f.read()
print(output)
The next day I started actually searching for examples of how to get the output from subprocess.run() into a variable, and not long after that, decided to change my overall approach.
With this one, every visitor (including me) gets a freshly generated page, which is then cached, as long as the existing cached version was last modified at least 10 minutes prior to the visit. Otherwise the cached version gets served. No need for a cron job, no need to check a client cert in order to avoid having my server be used as a 'force multiplier', generating masses of requests to other people's servers.
I also made a couple small tweaks to how the output gets printed to the screen. Much happier with this version.
Installation:
install gtl (link at top of post)
copy script to your server's cgi folder
fill in the variables at the top of main()
Example 2, the better version:
#!/usr/bin/env python3
import subprocess
import os
import time
def main():
######################## fill in these variables #####################
out_limit = 50 # number of posts to get & display
refresh_limit = 600 # rate limit for running gtl (once per x seconds)
username = "your-username"
saved_file = "/path/to/tinylog-save-file"
gtl_path = "/path/to/gtl"
######################################################################
print ("20 text/gemini; charset=utf-8\r\n")
print ("# TinyLogRoll")
print ("=> index.gmi home")
print ("generated by gtl:")
print ("=> https://codeberg.org/fab/gtl/src/branch/main/docs/mode-gemini.md")
# check if save file exists and if so, check when it was last modified
if os.path.exists(saved_file):
age = file_age_check(saved_file)
if age > refresh_limit:
# run gtl and overwrite the save file
raw_posts = run_gtl(gtl_path, out_limit)
with open(saved_file, 'w') as out_file:
out_file.write(raw_posts)
else:
print(f"last gtl run was only {age} seconds ago; rate limit is once per 600 seconds.")
print(f"loading from saved file instead")
else:
# first run
print(f"## Welcome, {username}!")
raw_posts = run_gtl(gtl_path, out_limit)
with open(saved_file, 'w') as out_file:
out_file.write(raw_posts)
# print the saved data to the browser
with open(saved_file, 'r') as f:
output = f.read()
print_the_lines(output)
def run_gtl(path_to_gtl, post_limit):
# need to get stdout only; return value of subprocess.run() is simply a completion code
output = subprocess.run([f"{path_to_gtl}", "--mode", "gemini", "--limit", f"{post_limit}"], encoding='utf-8', timeout=10, stdout=subprocess.PIPE)
return output.stdout
def print_the_lines(raw_input):
post_lines = raw_input.split("\n")
for line in post_lines:
# remove links to non-subscribed tinylogs (commented out in gtl's subs list)
if line.startswith("=> #gemini://"):
continue
# speling and separator
elif (line == "Agregated tinylogs: "):
print("# ********\nAggregated tinylogs:\n")
else:
print(line)
def file_age_check(filename):
now = time.time()
then = os.path.getmtime(filename)
age_in_seconds = round(now - then)
return age_in_seconds
if __name__ == "__main__":
main()
Thanks for reading! Feedback and/or questions are welcome :)