Add Ruby harness for fetching podcast episodes
Parses the Art19 RSS feed for Cooking Issues, compares against
existing transcripts, and downloads missing episodes oldest-first.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..383b8bd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/audio/
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..00750ed
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..f66ab3b
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gem "nokogiri"
+gem "rake"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..1435373
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,38 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ nokogiri (1.19.2-aarch64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-musl)
+ racc (~> 1.4)
+ racc (1.8.1)
+ rake (13.3.1)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ nokogiri
+ rake
+
+BUNDLED WITH
+ 2.6.7
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..c86dc00
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require_relative "lib/feed"
+require_relative "lib/sync"
+
+task default: :sync
+
+desc "Fetch the feed, download episodes missing transcripts"
+task :sync do
+ feed = CookingIssues::Feed.new
+ episodes = feed.episodes
+ puts "Found #{episodes.size} episodes in feed."
+
+ sync = CookingIssues::Sync.new(episodes)
+ sync.run
+end
+
+desc "List all episodes from the feed"
+task :episodes do
+ feed = CookingIssues::Feed.new
+ feed.episodes.sort_by(&:number).each do |ep|
+ status = Dir.glob(File.join("transcripts", "#{ep.slug}.*")).any? ? "✓" : " "
+ puts "[#{status}] #{ep.number}. #{ep.title}"
+ end
+end
diff --git a/lib/feed.rb b/lib/feed.rb
new file mode 100644
index 0000000..2604dd8
--- /dev/null
+++ b/lib/feed.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require "net/http"
+require "nokogiri"
+require "uri"
+
+module CookingIssues
+ # Parses a podcast RSS feed and returns episode data.
+ class Feed
+ FEED_URL = "https://rss.art19.com/cooking-issues"
+
+ Episode = Struct.new(:number, :title, :published_at, :audio_url, keyword_init: true) do
+ def slug
+ formatted_number = format("%03d", number)
+ safe_title = title
+ .downcase
+ .gsub(/[^a-z0-9\s-]/, "")
+ .gsub(/\s+/, "-")
+ .gsub(/-+/, "-")
+ .sub(/-$/, "")
+ "#{formatted_number}-#{safe_title}"
+ end
+ end
+
+ def initialize(url = FEED_URL)
+ @url = url
+ end
+
+ def episodes
+ @episodes ||= parse(fetch)
+ end
+
+ private
+
+ def fetch
+ uri = URI(@url)
+ response = Net::HTTP.get_response(uri)
+ raise "Feed returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+
+ response.body
+ end
+
+ def parse(xml)
+ doc = Nokogiri::XML(xml)
+ doc.xpath("//item").filter_map { |item| parse_item(item) }
+ end
+
+ def parse_item(item)
+ number = item.at_xpath("itunes:episode")&.text&.to_i
+ return nil unless number && number > 0
+
+ Episode.new(
+ number: number,
+ title: item.at_xpath("title").text,
+ published_at: item.at_xpath("pubDate").text,
+ audio_url: item.at_xpath("enclosure")&.[]("url")
+ )
+ end
+ end
+end
diff --git a/lib/sync.rb b/lib/sync.rb
new file mode 100644
index 0000000..88c17a8
--- /dev/null
+++ b/lib/sync.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "net/http"
+require "uri"
+
+module CookingIssues
+ # Downloads episodes that don't yet have transcripts.
+ class Sync
+ AUDIO_DIR = File.expand_path("../audio", __dir__)
+ TRANSCRIPTS_DIR = File.expand_path("../transcripts", __dir__)
+
+ def initialize(episodes)
+ @episodes = episodes
+ end
+
+ def run
+ FileUtils.mkdir_p(AUDIO_DIR)
+ missing = untranscribed_episodes
+ if missing.empty?
+ puts "All episodes have transcripts."
+ return
+ end
+
+ puts "#{missing.size} episodes need transcripts."
+ missing.each do |episode|
+ download(episode)
+ end
+ end
+
+ private
+
+ def untranscribed_episodes
+ @episodes.reject { |ep| transcript_exists?(ep) }.sort_by(&:number)
+ end
+
+ def transcript_exists?(episode)
+ Dir.glob(File.join(TRANSCRIPTS_DIR, "#{episode.slug}.*")).any?
+ end
+
+ def download(episode)
+ dest = File.join(AUDIO_DIR, "#{episode.slug}.mp3")
+ if File.exist?(dest)
+ puts " already downloaded: #{episode.slug}"
+ return
+ end
+
+ puts " downloading: #{episode.slug}"
+ uri = URI(episode.audio_url)
+ download_with_redirects(uri, dest)
+ end
+
+ def download_with_redirects(uri, dest, limit = 5)
+ raise "Too many redirects" if limit == 0
+
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
+ request = Net::HTTP::Get.new(uri)
+ http.request(request) do |response|
+ case response
+ when Net::HTTPRedirection
+ download_with_redirects(URI(response["location"]), dest, limit - 1)
+ when Net::HTTPSuccess
+ File.open(dest, "wb") do |file|
+ response.read_body { |chunk| file.write(chunk) }
+ end
+ else
+ raise "Download failed for #{uri}: #{response.code}"
+ end
+ end
+ end
+ end
+ end
+end