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
change trslkvrvsoovwoooxtkpormuwxuvwnpx
commit 58c06534edb9616684b9008631d8e2e3cc94705d
author Alpha Chen <alpha@kejadlen.dev>
date
parent tlysmqpr
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