add podcasts/create-feed script

This commit is contained in:
2026-05-15 02:00:11 +03:30
parent 4cfc8774ce
commit 60bea383ae
2 changed files with 246 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.xml
*.json
+244
View File
@@ -0,0 +1,244 @@
"""
this script creates a feed.xml file based on JSON data of a podcast from apple podcast (itunes) API
"""
import json
from pathlib import Path
from datetime import datetime, timezone
from email.utils import format_datetime
from urllib.parse import urlparse
import xml.etree.ElementTree as ET
from xml.dom import minidom
# sample curl for season episodes details:
# "id": "1573565122-8",
# "type": "podcast-seasons",
# sample curl for channel details
# "type": "podcasts",
# "href": "/v1/catalog/us/podcasts/1573565122?l=en-US",
json_path = Path('season-7-episoded-formatted.json')
channel_path = Path('channel-details.json')
output_path = Path('Bibliocast-custom.xml')
obj = json.loads(json_path.read_text())
seasons = obj.get('data', [])
channel_obj = json.loads(channel_path.read_text()) if channel_path.exists() else {}
channel_data_list = channel_obj.get('data') or []
channel_attrs = channel_data_list[0].get('attributes', {}) if channel_data_list else {}
episodes = []
for season in seasons:
episodes.extend(season.get('relationships', {}).get('episodes', {}).get('data', []))
# Sort by releaseDateTime descending (latest first)
def parse_dt(value):
if not value:
return None
# ISO 8601 with Z
if value.endswith('Z'):
value = value.replace('Z', '+00:00')
try:
return datetime.fromisoformat(value)
except ValueError:
return None
episodes_sorted = sorted(
episodes,
key=lambda ep: parse_dt(ep.get('attributes', {}).get('releaseDateTime')) or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
# Channel-level data (from channel details, fallback to episode data)
first_attr = episodes_sorted[0]['attributes'] if episodes_sorted else {}
channel_title = channel_attrs.get('name') or 'Bibliocast'
artist_name = channel_attrs.get('artistName') or first_attr.get('artistName')
artwork_url = (channel_attrs.get('artwork') or {}).get('url') or (first_attr.get('artwork') or {}).get('url')
website_url = channel_attrs.get('websiteUrl') or first_attr.get('websiteUrl')
base_link = None
if website_url:
parsed = urlparse(website_url)
base_link = f"{parsed.scheme}://{parsed.netloc}/" if parsed.scheme and parsed.netloc else None
channel_description = None
desc = channel_attrs.get('description')
if isinstance(desc, dict):
channel_description = desc.get('standard') or desc.get('short')
elif isinstance(desc, str):
channel_description = desc
if not channel_description:
channel_description = 'Custom feed generated from Bibliocast episode data.'
copyright_text = channel_attrs.get('copyright') or first_attr.get('copyright')
# genre
genres = set(channel_attrs.get('genreNames') or [])
explicit_values = set()
for ep in episodes_sorted:
attrs = ep.get('attributes', {})
for g in (attrs.get('genreNames') or []):
if g:
genres.add(g)
rating = (attrs.get('contentRating') or '').strip().lower()
if rating:
explicit_values.add(rating)
channel_explicit = None
channel_rating = channel_attrs.get('contentRating')
if channel_rating:
channel_rating = channel_rating.strip().lower()
if channel_rating == 'explicit':
channel_explicit = 'yes'
elif channel_rating == 'clean':
channel_explicit = 'clean'
elif channel_rating == 'notexplicit':
channel_explicit = 'no'
if not channel_explicit:
if 'explicit' in explicit_values:
channel_explicit = 'yes'
elif 'clean' in explicit_values:
channel_explicit = 'clean'
channel_release_dt = parse_dt(channel_attrs.get('releaseDateTime'))
# Namespaces
itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd'
ET.register_namespace('itunes', itunes_ns)
rss = ET.Element('rss', {'version': '2.0'})
channel = ET.SubElement(rss, 'channel')
# Required/standard channel elements
ET.SubElement(channel, 'title').text = channel_title
if website_url:
ET.SubElement(channel, 'link').text = website_url
elif base_link:
ET.SubElement(channel, 'link').text = base_link
if channel_description:
ET.SubElement(channel, 'description').text = channel_description
if copyright_text:
ET.SubElement(channel, 'copyright').text = copyright_text
if channel_release_dt:
ET.SubElement(channel, 'lastBuildDate').text = format_datetime(channel_release_dt)
elif episodes_sorted:
latest_dt = parse_dt(episodes_sorted[0].get('attributes', {}).get('releaseDateTime'))
if latest_dt:
ET.SubElement(channel, 'lastBuildDate').text = format_datetime(latest_dt)
# iTunes channel elements
if artist_name:
ET.SubElement(channel, f'{{{itunes_ns}}}author').text = artist_name
if channel_explicit:
ET.SubElement(channel, f'{{{itunes_ns}}}explicit').text = channel_explicit
if artwork_url:
ET.SubElement(channel, f'{{{itunes_ns}}}image', {'href': artwork_url})
# iTunes category (use first genre, if any)
if genres:
for genre in sorted(genres):
ET.SubElement(channel, f'{{{itunes_ns}}}category', {'text': genre})
# Helpers
def duration_to_itunes(ms):
if ms is None:
return None
try:
total_seconds = int(round(ms / 1000))
except Exception:
return None
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
def rating_to_itunes(value):
if not value:
return None
value = value.strip().lower()
if value == 'explicit':
return 'yes'
if value == 'clean':
return 'clean'
if value == 'notexplicit':
return 'no'
return None
for ep in episodes_sorted:
attrs = ep.get('attributes', {})
item = ET.SubElement(channel, 'item')
title = attrs.get('itunesTitle') or attrs.get('name')
if title:
ET.SubElement(item, 'title').text = title
ET.SubElement(item, f'{{{itunes_ns}}}title').text = title
link = attrs.get('websiteUrl') or attrs.get('url')
if link:
ET.SubElement(item, 'link').text = link
guid = attrs.get('guid')
if guid:
guid_el = ET.SubElement(item, 'guid', {'isPermaLink': 'false'})
guid_el.text = guid
# Description
desc = (attrs.get('description') or {}).get('standard') or (attrs.get('description') or {}).get('short')
if desc:
ET.SubElement(item, 'description').text = desc
ET.SubElement(item, f'{{{itunes_ns}}}summary').text = desc
short_desc = (attrs.get('description') or {}).get('short')
if short_desc:
ET.SubElement(item, f'{{{itunes_ns}}}subtitle').text = short_desc
# Publication date
release = attrs.get('releaseDateTime')
release_dt = parse_dt(release)
if release_dt:
ET.SubElement(item, 'pubDate').text = format_datetime(release_dt)
# Enclosure
asset_url = attrs.get('assetUrl')
if asset_url:
ET.SubElement(item, 'enclosure', {'url': asset_url, 'type': 'audio/mpeg'})
# iTunes fields
if artist_name:
ET.SubElement(item, f'{{{itunes_ns}}}author').text = artist_name
season_number = attrs.get('seasonNumber')
if season_number is not None:
ET.SubElement(item, f'{{{itunes_ns}}}season').text = str(season_number)
episode_number = attrs.get('episodeNumber')
if episode_number is not None:
ET.SubElement(item, f'{{{itunes_ns}}}episode').text = str(episode_number)
duration = duration_to_itunes(attrs.get('durationInMilliseconds'))
if duration:
ET.SubElement(item, f'{{{itunes_ns}}}duration').text = duration
rating = rating_to_itunes(attrs.get('contentRating'))
if rating:
ET.SubElement(item, f'{{{itunes_ns}}}explicit').text = rating
art = (attrs.get('artwork') or {}).get('url')
if art:
ET.SubElement(item, f'{{{itunes_ns}}}image', {'href': art})
# Pretty print
rough_string = ET.tostring(rss, encoding='utf-8')
parsed = minidom.parseString(rough_string)
pretty_xml = parsed.toprettyxml(indent=' ', encoding='utf-8')
output_path.write_bytes(pretty_xml)
print(f'Wrote {output_path} with {len(episodes_sorted)} episodes')