add podcasts/create-feed script
This commit is contained in:
@@ -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')
|
||||
Reference in New Issue
Block a user