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)