.-----------------------------------------------------------------------------. | 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 about spartan: finger spartanware@happynetbox.com | '-----------------------------------------------------------------------------' #!/usr/bin/env python3 """ .-------------------------------------------------------------------. | This project is licensed under the Blue Oak Model License 1.0.0. | | See: https://blueoakcouncil.org/license/1.0.0 | '-------------------------------------------------------------------' .-------------------------------------------------------------------. | 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 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 | '-------------------------------------------------------------------' """ 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.0 ---------------------------------------- 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: {os.path.basename(sys.argv[0])} spartan://host/resource {os.path.basename(sys.argv[0])} path/to/file.gmi {os.path.basename(sys.argv[0])} spartan://host/search --infile query.txt \033[1m{os.path.basename(sys.argv[0])} 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: spartanware@happynetbox.com """ # Show help if no arguments or any help flag is passed 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 (spartanware@happynetbox.com / ChatGPT) --- def render_gemtext(fp): # Dynamically determine the terminal width 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 # Track whether the last line was empty for raw_line in fp: line = raw_line.decode("utf-8", errors="replace").rstrip() # Toggle preformatted text block on ``` lines if line.strip() == "```": in_preformatted = not in_preformatted continue # Don't show ``` lines if in_preformatted: print(line) # Print preformatted block verbatim last_line_empty = False continue # Render headers if line.startswith("### "): print(f"\033[4m{line[4:]}\033[0m") # Underlined (h3) last_line_empty = False elif line.startswith("## "): print(f"\033[1m{line[3:]}\033[0m") # Bold (h2) last_line_empty = False elif line.startswith("# "): print(f"\033[1;4m{line[2:]}\033[0m") # Bold + Underlined (h1) last_line_empty = False # Render links elif line.startswith("=>"): parts = line[2:].strip().split(maxsplit=1) url = parts[0] label = parts[1] if len(parts) > 1 else url print(f"\033[38;5;110m→ {label} [{url}]\033[0m") last_line_empty = False # Render blockquotes elif line.startswith("> "): print(f"\033[2m{line[2:]}\033[0m") # Dim last_line_empty = False # Render list items elif line.startswith("* "): print(f"* {line[2:]}") last_line_empty = False # Horizontal rules (3 to 10 dashes) elif 3 <= len(line.strip()) <= 10 and set(line.strip()) == {"-"}: print("________________________________________________________________________") last_line_empty = False # Paragraph spacing elif line.strip() == "": if not last_line_empty: print() last_line_empty = True # Word-wrap default lines else: for wrapped_line in wrapper.wrap(line): print(wrapped_line) last_line_empty = False # --- END Gemtext Renderer (spartanware@happynetbox.com / ChatGPT) --- # --- 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 (spartanware@happynetbox.com / ChatGPT) --- 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 --- .-----------------------------. .--------------. .---------------------------. |spartanviewer@happynetbox.com|-| May 08, 2025 |-| neocities.org/spartanware | '-----------------------------' '--------------' '---------------------------'