File: podfeed.py
   1 #!/usr/bin/python
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright (c) 2026 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the "Software"), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 from datetime import datetime
  27 from html import escape
  28 from multiprocessing import Pool
  29 from sys import argv, exit, stderr, stdin
  30 from typing import Dict, List
  31 from urllib.parse import urlparse, urlunparse
  32 from urllib.request import urlopen
  33 from xml.dom.minidom import parse
  34 
  35 
  36 info = '''
  37 podfeed [options...] [filepaths/URIs...]
  38 
  39 
  40 PODcast FEED fetches all episodes from the feeds given as URIs, either as
  41 arguments, or as lines in the plain-text files given.
  42 
  43 The result is self-contained HTML which links to all episodes, and adds
  44 many little extras, such as tooltips showing date of publication and play
  45 duration.
  46 
  47 Podcast thumbnails aren't included as inline data-URIs, to avoid making
  48 the output size considerably bigger; they could easily source external
  49 URIs, but doing that would make the output no longer fully self-contained.
  50 
  51 All (optional) leading options start with either single or double-dash,
  52 and most of them change the style/color used. Some of the options are,
  53 shown in their single-dash form:
  54 
  55     -h, -help    show this help message
  56     -title       use the next argument as the title in the HTML output
  57 '''
  58 
  59 # a leading help-option arg means show the help message and quit
  60 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
  61     print(info.strip())
  62     exit(0)
  63 
  64 
  65 def fail(msg, code: int = 1) -> None:
  66     'Show the error message given, and quit the app right away.'
  67     print(str(msg), file=stderr)
  68     exit(code)
  69 
  70 
  71 # handle leading cmd-line options
  72 title = ''
  73 start_args = 1
  74 while start_args < len(argv) and argv[start_args].startswith('-'):
  75     l = argv[start_args].lstrip('-').lower()
  76     if l in ('title'):
  77         if start_args + 1 >= len(argv):
  78             fail('missing actual title in cmd-line arguments', 1)
  79         title = argv[start_args + 1]
  80         start_args += 2
  81         continue
  82     break
  83 args = argv[start_args:]
  84 
  85 # use a default web-page title if one wasn't given
  86 if title == '':
  87     now = datetime.now()
  88     ymd = f'{now.year}-{now.month:02}-{now.day:02}'
  89     hms = f'{now.hour}:{now.minute:02}:{now.second:02}'
  90     title = f'Latest Podcast Episodes as of {ymd} {hms}'
  91 
  92 
  93 def parse_feed(uri: str) -> Dict:
  94     'Turn an XML feed into dictionaries, given the feed\'s URI.'
  95 
  96     res = {'rss': []}
  97     with urlopen(uri) as inp:
  98         feed = parse(inp)
  99         for rss in feed.getElementsByTagName('rss'):
 100             channels = rss.getElementsByTagName('channel')
 101             channels = [parse_channel(chan) for chan in channels]
 102             res['rss'].append({'channels': channels})
 103     return res
 104 
 105 
 106 def parse_channel(chan) -> Dict:
 107     'Help func parse_feed do its job.'
 108 
 109     title = get_str(chan, 'title')
 110     link = get_str(chan, 'link')
 111     descr = get_str(chan, 'description')
 112     # no channel thumbnail for now
 113 
 114     episodes = chan.getElementsByTagName('item')
 115     episodes = [parse_episode(ep) for ep in episodes]
 116 
 117     return {
 118         'title': title,
 119         'link': link,
 120         'description': descr,
 121         'episodes': episodes,
 122     }
 123 
 124 
 125 def parse_episode(episode) -> Dict:
 126     'Help func parse_channel do its job.'
 127 
 128     title = get_str(episode, 'title')
 129     link = get_str(episode, 'link')
 130     description = get_str(episode, 'description')
 131     pub_date = get_str(episode, 'pubDate')
 132     duration = get_str(episode, 'itunes:duration')
 133     for enc in episode.getElementsByTagName('enclosure'):
 134         link = enc.getAttribute('url')
 135 
 136     return {
 137         'title': title,
 138         'link': link,
 139         'description': description,
 140         'pub_date': pub_date,
 141         'duration': duration,
 142     }
 143 
 144 
 145 def render_feed(feed) -> None:
 146     'Handle a single parsed RSS feed.'
 147 
 148     indent = 12 * ' '
 149     print('        <article>')
 150 
 151     for rss in feed['rss']:
 152         for chan in rss['channels']:
 153             href = urlunparse(urlparse(chan['link']))
 154             title = escape(chan['title'])
 155             descr = escape(chan['description'])
 156             a = make_anchor(href, title)
 157             s = f'{indent}<h1><summary title="{descr}">{a}</summary></h1>'
 158             print(s)
 159             # no channel thumbnail for now
 160 
 161             for episode in chan['episodes']:
 162                 render_episode(episode)
 163 
 164     print('        </article>')
 165 
 166 
 167 def render_episode(episode) -> None:
 168     'Help func render_feed do its job.'
 169 
 170     title = escape(episode['title'])
 171     href = urlunparse(urlparse(episode['link']))
 172     description = escape(episode['description'])
 173     pub_date = escape(episode['pub_date'])
 174     duration = escape(episode['duration'])
 175     tt = make_tooltip(pub_date, duration)
 176     a = make_anchor(href, title)
 177 
 178     print('            <section>')
 179     print('                <details>')
 180     print(f'                    <summary title="{tt}">{a}</summary>')
 181     print(f'                    <p>{description}</p>')
 182     print('                </details>')
 183     print('            </section>')
 184 
 185 
 186 def make_anchor(href: str, title: str) -> str:
 187     'Standardize how hyperlinks are handled in this script.'
 188     return f'<a target="_blank" rel="noreferrer" href="{href}">{title}</a>'
 189 
 190 
 191 def make_tooltip(pub_date: str, duration: str) -> str:
 192     try:
 193         # because datetime's supposedly-idiomatic solutions are so ugly
 194         s = int(duration)
 195         h = int(s / 3600)
 196         m = int(s / 60) % 3600
 197         s %= 60
 198         hms = f'{h:02}:{m:02}:{s:02}'.lstrip('00:')
 199     except Exception:
 200         hms = duration
 201     return f'published: {pub_date} | duration: {hms}'
 202 
 203 
 204 def get_str(src, tag: str) -> str:
 205     'Simplify the control-flow of various feed-parsing funcs.'
 206 
 207     try:
 208         res = src.getElementsByTagName(tag)
 209         if len(res) == 0:
 210             return ''
 211         for e in res[0].childNodes:
 212             if e.nodeType in (e.TEXT_NODE, e.CDATA_SECTION_NODE):
 213                 return e.data.strip()
 214         return ''
 215     except Exception:
 216         return ''
 217 
 218 
 219 def get_uris(src) -> List[str]:
 220     'This func helps func load_feed_uris load all URIs from a file.'
 221 
 222     uris = []
 223     for line in src:
 224         line = line.rstrip('\r\n').rstrip('\n').strip()
 225         if line == '' or line.startswith('#'):
 226             continue
 227         uris.append(line)
 228     return uris
 229 
 230 
 231 def load_feed_uris(args: List[str]) -> List[str]:
 232     'Turn a mix of URIs and filepaths into a list of URIs to load.'
 233 
 234     if args.count('-') > 1:
 235         msg = 'reading from `-` (standard input) more than once not allowed'
 236         raise ValueError(msg)
 237 
 238     if len(args) == 0:
 239         return get_uris(stdin)
 240 
 241     uris = []
 242 
 243     for path in args:
 244         if path.startswith('https://') or path.startswith('http://'):
 245             uris.append(path)
 246             continue
 247 
 248         if path == '-':
 249             uris.extend(get_uris(stdin))
 250 
 251         with open(path, encoding='utf-8') as inp:
 252             uris.extend(get_uris(inp))
 253 
 254     return uris
 255 
 256 
 257 # style is the `inner` CSS used inside the style tag
 258 style = '''
 259         body {
 260             font-size: 0.9rem;
 261             margin: 0 0 2rem 0;
 262             font-family: system-ui, -apple-system, sans-serif;
 263         }
 264 
 265         main {
 266             margin: auto;
 267             display: flex;
 268             width: fit-content;
 269         }
 270 
 271         h1 {
 272             top: 0;
 273             position: sticky;
 274             font-size: 0.9rem;
 275             text-align: center;
 276             background-color: white;
 277         }
 278 
 279         img {
 280             margin: auto;
 281             margin-bottom: 1rem;
 282             display: block;
 283             max-width: 15ch;
 284         }
 285 
 286         section {
 287             width: 48ch;
 288             padding: 0.3rem;
 289             margin: 0 0.1rem;
 290         }
 291 
 292         section:nth-child(2n+1) {
 293             background-color: #eee;
 294         }
 295 
 296         a {
 297             color: steelblue;
 298             text-decoration: none;
 299         }
 300 
 301         details p {
 302             line-height: 1.3rem;
 303         }
 304 '''.strip('\n')
 305 
 306 
 307 try:
 308     feeds = load_feed_uris(args)
 309 
 310     print('<!DOCTYPE html>')
 311     print('<html lang="en">')
 312     print('<head>')
 313     print('    <meta charset="UTF-8">')
 314     print('    <link rel="icon" href="data:,">')
 315     cattr = 'content="width=device-width, initial-scale=1.0"'
 316     print(f'    <meta name="viewport" {cattr}>')
 317     if title != '':
 318         print(f'    <title>{escape(title)}</title>')
 319     print('    <style>')
 320     print(style)
 321     print('    </style>')
 322     print('</head>')
 323     print('<body>')
 324     print('    <main>')
 325 
 326     # significantly speed-up script by loading/parsing feeds concurrently
 327     with Pool(processes=min(4, max(1, len(feeds)))) as pool:
 328         feeds = pool.map(parse_feed, feeds)
 329 
 330     for feed in feeds:
 331         render_feed(feed)
 332 
 333     print('    </main>')
 334     print('</body>')
 335     print('</html>')
 336 except BrokenPipeError:
 337     # quit quietly, instead of showing a confusing error message
 338     stderr.close()
 339 except KeyboardInterrupt:
 340     exit(2)
 341 except Exception as e:
 342     fail(e, 1)