add podcasts/create-feed script
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
*.xml
|
||||||
|
*.json
|
||||||
@@ -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