#!/usr/bin/env python3 """ .----------------------------------------------------------------------------. | This script fetches gemtext pages using the spartan:// protocol. | | | | It's not a full browser, but rather a reference implementation originally | | designed by Michael Lazar to: | | | | * retrieve single pages | | * follow redirects | | * upload files for specific page requests using "--infile" | | | | The primary goal was to demonstrate full interaction with the spartan | | protocol and as a template for further spartan development. | | | | Main Additions to the script include: | | | | * gemtext (gmi) rendering | | * smart word-wrap | | * local file support (sv path/to/filename.gmi) | | * a new built-in help | | | | The script retains the original spartan code and remains a single-page | | retriever at its core. | | | | For more information: https://640kb.neocities.org/apps/apps.html | '----------------------------------------------------------------------------' .----------------------------------------------------------------------------. | This project is licensed under the Blue Oak Model License 1.0.0. | | See: https://blueoakcouncil.org/license/1.0.0 | '----------------------------------------------------------------------------' """ import argparse import shutil import socket import sys import textwrap import os from urllib.parse import urlparse, unquote_to_bytes, quote_from_bytes # --- BEGIN NEW USER ADDITIONS: New Help Section --- custom_help = f""" sv: Spartan/Gemtext Viewer - Version 3.4 ---------------------------------------- Fetches and renders a Spartan URL or local .gmi file as styled Gemtext. Output is ANSI-formatted and auto-wraps to fit your terminal width. Usage: sv spartan://host/resource sv path/to/file.gmi sv spartan://host/search --infile query.txt \033[1msv spartan://host/page.gmi | less -R\033[0m Features: - Spartan Fetching Connects to a Spartan server and fetches content - Local File Support View local .gmi files directly (offline mode) - Gemtext Rendering Styled headers, links, lists, quotes, rules - Smart Word-Wrap Adapts to terminal size for clean display - --infile Support Sends file contents as Spartan input (e.g. forms) - Redirect Handling Follows simple Spartan redirects (status 3) License: Blue Oak Model License 1.0.0 Author: Michael Lazar | Enhancements: 640kb.neocities.org """ if len(sys.argv) == 1 or any(arg.lower() in ("/?", "-?", "-h", "--h", "--help", "-help") for arg in sys.argv[1:]): print(custom_help) sys.exit(0) # --- END NEW USER ADDITIONS: New Help Section --- # --- BEGIN NEW USER FUNCTION: Gemtext Renderer --- def render_gemtext(fp): term_width = shutil.get_terminal_size((80, 20)).columns wrapper = textwrap.TextWrapper(width=term_width, replace_whitespace=False) in_preformatted = False last_line_empty = False for raw_line in fp: line = raw_line.decode("utf-8", errors="replace").rstrip() if line.strip() == "```": in_preformatted = not in_preformatted continue if in_preformatted: print(line) last_line_empty = False continue if line.startswith("### "): print(f"\033[4m{line[4:]}\033[0m") last_line_empty = False elif line.startswith("## "): print(f"\033[1m{line[3:]}\033[0m") last_line_empty = False elif line.startswith("# "): print(f"\033[1;4m{line[2:]}\033[0m") last_line_empty = False elif line.startswith("=>"): parts = line[2:].strip().split(maxsplit=1) url = parts[0] label = parts[1] if len(parts) > 1 else url ansi_star = "\033[97m*\033[0m " indent = " " ansi_link = "\033[38;5;110m" ansi_reset = "\033[0m" plain_text = f"{label} ({url})" wrapper_sub = textwrap.TextWrapper( width=term_width, initial_indent="", subsequent_indent=indent, replace_whitespace=False ) wrapped_lines = wrapper_sub.wrap(plain_text) if wrapped_lines: print(f"{ansi_star}{ansi_link}{wrapped_lines[0]}{ansi_reset}") for line in wrapped_lines[1:]: print(f"{indent}{ansi_link}{line}{ansi_reset}") last_line_empty = False elif line.startswith("> "): print(f"\033[2m{line[2:]}\033[0m") last_line_empty = False elif line.startswith("* "): ansi_star = "\033[97m*\033[0m " ansi_text = "\033[93m" ansi_reset = "\033[0m" indent = " " content = line[2:].strip() wrapper_li = textwrap.TextWrapper( width=term_width, initial_indent="", subsequent_indent=indent, replace_whitespace=False ) wrapped_lines = wrapper_li.wrap(content) if wrapped_lines: print(f"{ansi_star}{ansi_text}{wrapped_lines[0]}{ansi_reset}") for subline in wrapped_lines[1:]: print(f"{indent}{ansi_text}{subline}{ansi_reset}") last_line_empty = False elif 3 <= len(line.strip()) <= 10 and set(line.strip()) == {"-"}: print("________________________________________________________________________") last_line_empty = False elif line.strip() == "": if not last_line_empty: print() last_line_empty = True else: for wrapped_line in wrapper.wrap(line): print(wrapped_line) last_line_empty = False # --- END Gemtext Renderer --- # --- BEGIN ORIGINAL FUNCTION FROM MICHAEL LAZAR --- def fetch_url(url, infile=None): url_parts = urlparse(url) if url_parts.scheme != "spartan": raise ValueError("Unrecognized URL scheme") host = url_parts.hostname port = url_parts.port or 300 path = url_parts.path or "/" query = url_parts.query redirect_url = None with socket.create_connection((host, port)) as sock: if infile: data = infile.read() elif query: data = unquote_to_bytes(query) else: data = b"" encoded_host = host.encode("idna") encoded_path = quote_from_bytes(unquote_to_bytes(path)).encode("ascii") sock.send(b"%s %s %d\r\n" % (encoded_host, encoded_path, len(data))) sock.send(data) fp = sock.makefile("rb") response = fp.readline(4096).decode("ascii").strip("\r\n") print(response, file=sys.stderr, flush=True) parts = response.split(" ", maxsplit=1) code, meta = int(parts[0]), parts[1] if code == 2: render_gemtext(fp) elif code == 3: redirect_url = url_parts._replace(path=meta).geturl() if redirect_url: fetch_url(redirect_url) # --- END ORIGINAL FUNCTION FROM MICHAEL LAZAR --- # --- BEGIN MODIFIED ARGUMENT PARSING (Based on Michael Lazar) --- # --- Modified for New Help Support re: command-line order consistency --- parser = argparse.ArgumentParser(add_help=False) # Disable default -h/--help parser.add_argument("url") parser.add_argument("--infile", type=argparse.FileType("rb")) args = parser.parse_args() # --- END MODIFIED ARGUMENT PARSING CODE --- # --- BEGIN NEW USER ADDITIONS: Local Gemtext File Support --- def try_local_file_or_spartan(url, infile=None): if os.path.isfile(url): ext = os.path.splitext(url)[1].lower() with open(url, "rb") as fp: if ext == ".gmi": render_gemtext(fp) else: for line in fp: print(line.decode("utf-8", errors="replace").rstrip()) else: fetch_url(url, infile) # --- END NEW USER ADDITIONS: Local Gemtext File Support --- # --- BEGIN MODIFIED FINAL CALL: Local or Spartan Dispatch --- try: try_local_file_or_spartan(args.url, args.infile) except ValueError as e: print(f"Error: {e}", file=sys.stderr) except KeyboardInterrupt: pass # --- END MODIFIED FINAL CALL --- # .-------------------------------------------------------------------------. # | CHANGELOG | # '-------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 3.4 : Minor editing, reorganization (12feb2026) | # '--------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 3.3 : Minor editing, reorganization (03oct2025) | # '--------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 3.2 : Updated links, minor editing (03oct2025) | # '--------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 3.1 : Links and List Item rendering (07jun2025) | # | -------------------------------------------------------------------------| # | Improved how links are rendered: | # | - links such as "=> spartan://example.com title" are now treated like | # | list items. Wrapping fixed. | # | | # | list items : | # | - any line that starts with an "*" is treated like a list item. To | # | differentiate from links, these 'list items' are yellow. | # | | # | Note: other markers may be used to indicate 'list items'. I'll update | # | this script if I see they are commonly used. | # '--------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 3.0 : Local File Support (06may2025) | # | -------------------------------------------------------------------------| # | Added support for local Gemtext files: | # | - Can now pass a .gmi file or path directly on the command line | # | - All other extensions (txt, etc) will only be displayed not rendered | # | | # | Unified Dispatcher: | # | - New logic routes either to file renderer or Spartan fetcher | # | | # | Official Name: 'Spartan Viewer' and/or sv | # '--------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 2.0 : GemText Rendering, Word-wrap... (04may2025) | # | -------------------------------------------------------------------------| # | Added Gemtext rendering: | # | - Styled output for headers, links, blockquotes, lists, and | # | horizontal rules using ANSI escape codes. | # | - Smart paragraph spacing and formatting. | # | | # | Smart word-wrap | # | - Dynamically adapts text wrapping to fit the current terminal | # | width using shutil.get_terminal_size() and textwrap | # | | # | Added ANSI-styled Gemtext output for use with tools like less -R | # | | # | Enhanced Help Section | # | - custom help, multiple -flags or no arguments | # '--------------------------------------------------------------------------' # .--------------------------------------------------------------------------. # | Version 1.0 : Initial release | # | -------------------------------------------------------------------------| # | Original work by Michael Lazar | # | | # | A reference spartan:// protocol client | # | | # | Copyright (c) Michael Lazar | # | Blue Oak Model License 1.0.0 | # '--------------------------------------------------------------------------'