module YtLookup
Constants
- INNERTUBE_CLIENT
- INNERTUBE_PLAYER_URL
- PLAYLIST_URL_RE
- YOUTUBE_URL_RE
Public Instance Methods
Source
# File lib/yt_lookup.rb, line 274 def best_stream_quality(streaming_data) return nil unless streaming_data.is_a?(Hash) formats = (streaming_data["formats"] || []) + (streaming_data["adaptiveFormats"] || []) video_formats = formats.select { |format| format["mimeType"].to_s.start_with?("video/") } return nil if video_formats.empty? best = video_formats.max_by do |format| [format["height"].to_i, format["fps"].to_i, format["bitrate"].to_i] end label = best["qualityLabel"].to_s.strip if label.empty? height = best["height"].to_i return nil unless height.positive? fps = best["fps"].to_i label = fps > 30 ? "#{height}p#{fps}" : "#{height}p" end label end
Source
# File lib/yt_lookup.rb, line 423 def default_fetch_oembed(video_id) url = "https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=#{video_id}&format=json" RabbotHttp.fetch(url, user_agent: "Mozilla/5.0") end
Source
# File lib/yt_lookup.rb, line 428 def default_fetch_page(video_id) url = "https://www.youtube.com/watch?v=#{video_id}" RabbotHttp.fetch(url, user_agent: "Mozilla/5.0") end
Source
# File lib/yt_lookup.rb, line 222 def extract_innertube_api_key(html) html.to_s[/INNERTUBE_API_KEY":"([^"]+)"/, 1] end
Source
# File lib/yt_lookup.rb, line 59 def extract_json_object(html, marker) start = html.to_s.index(marker) return nil unless start json_start = start + marker.length depth = 0 html[json_start..].each_char.with_index do |char, index| depth += 1 if char == "{" depth -= 1 if char == "}" return html[json_start, index + 1] if depth.zero? end nil end
Source
# File lib/yt_lookup.rb, line 42 def extract_playlist_id(url) match = url.to_s.match(PLAYLIST_URL_RE) match&.[](1) end
Source
# File lib/yt_lookup.rb, line 37 def extract_video_id(url) match = url.to_s.match(YOUTUBE_URL_RE) match&.[](1) end
Source
# File lib/yt_lookup.rb, line 252 def fetch_innertube_duration(video_id, html, post: method(:innertube_player_post)) data = fetch_innertube_player(video_id, html, post: post) return nil unless data seconds = data.dig("videoDetails", "lengthSeconds").to_i seconds.positive? ? seconds : nil end
Source
# File lib/yt_lookup.rb, line 260 def fetch_innertube_metadata(video_id, html, post: method(:innertube_player_post)) data = fetch_innertube_player(video_id, html, post: post) return {} unless data metadata = {} seconds = data.dig("videoDetails", "lengthSeconds").to_i metadata[:duration] = seconds if seconds.positive? quality = best_stream_quality(data["streamingData"]) metadata[:quality] = quality if quality metadata end
Source
# File lib/yt_lookup.rb, line 236 def fetch_innertube_player(video_id, html, post: method(:innertube_player_post)) api_key = extract_innertube_api_key(html) return nil if api_key.nil? || api_key.empty? json = post.call( api_key, { context: { client: INNERTUBE_CLIENT }, videoId: video_id } ) JSON.parse(json) rescue JSON::ParserError, StandardError nil end
Source
# File lib/yt_lookup.rb, line 47 def find_url(text) match = text.to_s.match(YOUTUBE_URL_RE) match&.[](0) end
Source
# File lib/yt_lookup.rb, line 162 def format_duration(seconds, text = nil) total = seconds.to_i total = parse_duration_text(text) if total <= 0 return "" if total <= 0 hours, remainder = total.divmod(3600) minutes, secs = remainder.divmod(60) if hours.positive? format("%dh:%02dm:%02ds", hours, minutes, secs) else format("%dm:%02ds", minutes, secs) end end
Source
# File lib/yt_lookup.rb, line 98 def format_likes(count) text = count.to_s.strip return text if text.match?(/\A\d[\d,.]*[KMB]?\s+likes?\z/i) return "#{text} likes" if text.match?(/\A\d[\d,.]*[KMB]\z/i) n = text.gsub(/[^\d]/, "").to_i return "" if n.zero? if n >= 1_000_000 value = (n / 1_000_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}M likes" elsif n >= 1_000 value = (n / 1_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}K likes" else "#{n} likes" end end
Source
# File lib/yt_lookup.rb, line 176 def format_published_date(text) value = text.to_s.strip return "" if value.empty? if value =~ /\A(\d{4}-\d{2}-\d{2})/ Date.parse(Regexp.last_match(1)).strftime("%-d %b %Y") else value end rescue Date::Error value end
Source
# File lib/yt_lookup.rb, line 119 def format_subscribers(count) text = count.to_s.strip return text if text.match?(/\A\d[\d,.]*[KMB]?\s+subscribers?\z/i) n = text.gsub(/[^\d]/, "").to_i return "" if n.zero? if n >= 1_000_000 value = (n / 1_000_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}M subscribers" elsif n >= 1_000 value = (n / 1_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}K subscribers" else "#{n} subscribers" end end
Source
# File lib/yt_lookup.rb, line 202 def format_time_ago(published, now: Time.now) from_time = published.is_a?(Time) ? published : parse_published_time(published) return "" unless from_time to_time = now return "" if to_time < from_time YtTimeAgo.compact_ago((to_time - from_time).to_i) end
Source
# File lib/yt_lookup.rb, line 74 def format_views(count) text = count.to_s.strip return text if text.match?(/\A\d[\d,.]*[KMB]?\s+views\z/i) n = text.gsub(/[^\d]/, "").to_i return "0 views" if n.zero? if n >= 1_000_000_000 value = (n / 1_000_000_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}B views" elsif n >= 1_000_000 value = (n / 1_000_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}M views" elsif n >= 1_000 value = (n / 1_000.0).round(1) value = value.to_i if (value % 1).zero? "#{value}K views" else "#{n} views" end end
Source
# File lib/yt_lookup.rb, line 226 def innertube_player_post(api_key, body) uri = URI("#{INNERTUBE_PLAYER_URL}?key=#{CGI.escape(api_key)}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" request.body = JSON.generate(body) http.request(request).body end
Source
# File lib/yt_lookup.rb, line 390 def lookup(url, fetch_oembed:, fetch_page:) video_id = extract_video_id(url) return "Could not parse YouTube URL" unless video_id oembed = JSON.parse(fetch_oembed.call(video_id)) title = oembed["title"].to_s.strip channel = oembed["author_name"].to_s.strip return "Could not fetch video info" if title.empty? html = fetch_page.call(video_id) metadata = merge_metadata(parse_initial_data(html), parse_microformat(html)) if metadata[:duration].to_i <= 0 || metadata[:quality].to_s.empty? innertube_meta = fetch_innertube_metadata(video_id, html) metadata[:duration] = innertube_meta[:duration] if metadata[:duration].to_i <= 0 && innertube_meta[:duration] metadata[:quality] ||= innertube_meta[:quality] end YtReport.format_message( title: title, channel: channel, views: metadata[:views] || "0", subscribers: metadata[:subscribers], likes: metadata[:likes], duration: metadata[:duration], duration_text: metadata[:duration_text], quality: metadata[:quality], published: metadata[:published], description: metadata[:description] || parse_description(html) ) rescue StandardError "Could not fetch video info" end
Source
# File lib/yt_lookup.rb, line 377 def merge_metadata(initial_data, microformat) { views: initial_data[:views] || microformat[:views], subscribers: initial_data[:subscribers], likes: initial_data[:likes], duration: initial_data[:duration] || microformat[:duration], duration_text: initial_data[:duration_text], quality: microformat[:quality], published: initial_data[:published] || microformat[:published], description: microformat[:description] } end
Source
# File lib/yt_lookup.rb, line 363 def parse_description(html) return "" if html.nil? || html.empty? if html =~ /"shortDescription":"((?:\\.|[^"\\])*)"/ return trim_description(JSON.parse(%Q{"#{Regexp.last_match(1)}"})) elsif html =~ /"description":\{"simpleText":"((?:\\.|[^"\\])*)"/ return trim_description(JSON.parse(%Q{"#{Regexp.last_match(1)}"})) end "" rescue JSON::ParserError, StandardError "" end
Source
# File lib/yt_lookup.rb, line 139 def parse_duration_text(text) value = text.to_s.strip return 0 if value.empty? if value.match?(/\A(\d+):(\d{2}):(\d{2})\z/) hours, minutes, seconds = value.split(":").map(&:to_i) return (hours * 3600) + (minutes * 60) + seconds end if value.match?(/\A(\d+):(\d{2})\z/) minutes, seconds = value.split(":").map(&:to_i) return (minutes * 60) + seconds end hours = value[/(\d+)\s*h(?:our|ours|r)?/i, 1].to_i minutes = value[/(\d+)\s*m(?:inute|inutes|in)?/i, 1].to_i seconds = value[/(\d+)\s*s(?:ec|econd|econds)?/i, 1].to_i total = (hours * 3600) + (minutes * 60) + seconds return total if total.positive? 0 end
Source
# File lib/yt_lookup.rb, line 297 def parse_initial_data(html) json = extract_json_object(html, "var ytInitialData = ") return {} unless json data = JSON.parse(json) contents = data.dig("contents", "twoColumnWatchNextResults", "results", "results", "contents") || [] primary = contents[0]&.dig("videoPrimaryInfoRenderer") || {} owner = contents[1]&.dig("videoSecondaryInfoRenderer", "owner", "videoOwnerRenderer") || {} metadata = { views: primary.dig("viewCount", "videoViewCountRenderer", "viewCount", "simpleText"), published: primary.dig("dateText", "simpleText"), subscribers: owner.dig("subscriberCountText", "simpleText") } walk_nodes(data) do |node| header = node["videoDescriptionHeaderRenderer"] next unless header metadata[:published] ||= header.dig("publishDate", "simpleText") metadata[:duration] ||= header["lengthInSeconds"] metadata[:duration_text] ||= header.dig("lengthText", "simpleText") (header["factoid"] || []).each do |factoid| renderer = factoid["factoidRenderer"] || factoid.dig("viewCountFactoidRenderer", "factoid", "factoidRenderer") next unless renderer next unless renderer.dig("label", "simpleText") == "Likes" metadata[:likes] ||= renderer.dig("value", "simpleText") end end metadata rescue JSON::ParserError, StandardError {} end
Source
# File lib/yt_lookup.rb, line 354 def parse_microformat(html) json = extract_json_object(html, "var ytInitialPlayerResponse = ") return {} unless json parse_player_response(JSON.parse(json)) rescue JSON::ParserError, StandardError {} end
Source
# File lib/yt_lookup.rb, line 335 def parse_player_response(data) return {} unless data.is_a?(Hash) details = data["videoDetails"] || {} microformat = data.dig("microformat", "playerMicroformatRenderer") || {} metadata = { views: details["viewCount"], duration: microformat["lengthSeconds"] || details["lengthSeconds"], published: microformat["publishDate"], description: trim_description(details["shortDescription"]) } quality = best_stream_quality(data["streamingData"]) metadata[:quality] = quality if quality metadata end
Source
# File lib/yt_lookup.rb, line 189 def parse_published_time(text) value = text.to_s.strip return nil if value.empty? if value =~ /\A\d{4}-\d{2}-\d{2}/ Time.parse(value) else Date.parse(value).to_time end rescue ArgumentError, Date::Error nil end
Source
# File lib/yt_lookup.rb, line 52 def trim_description(text) text = text.to_s.strip.gsub(/\s+/, " ") return "" if text.empty? text.split(/(?<=[.!?])\s+/).first.to_s.strip end
Source
# File lib/yt_lookup.rb, line 212 def walk_nodes(obj) case obj when Hash yield obj obj.each_value { |value| walk_nodes(value) { |node| yield node } } when Array obj.each { |value| walk_nodes(value) { |node| yield node } } end end