Merge 99c48181e51e215007b8d31a9f2e2d9c12176dc7 into 049d591d294e2a43cb32b97a0bb018c2093e5beb

This commit is contained in:
unfunnyatearug 2026-09-04 19:03:59 -07:00 committed by GitHub
commit 0d20286e5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 546 additions and 16 deletions

View File

@ -0,0 +1,442 @@
require "../../parsers_helper.cr"
Spectator.describe "parse_description" do
it "parses a description without any run" do
desc = JSON.parse(%({"content": "Hello <world> & \\"friends\\""}))
expect(parse_description(desc, "")).to eq(
"Hello &lt;world&gt; &amp; &quot;friends&quot;"
)
end
it "parses a description with a link (commandRuns)" do
desc = JSON.parse(<<-JSON)
{
"content": "Watch this",
"commandRuns": [
{
"startIndex": 0,
"length": 5,
"onTap": {
"innertubeCommand": {
"watchEndpoint": { "videoId": "dQw4w9WgXcQ" }
}
}
}
]
}
JSON
expect(parse_description(desc, "someOtherId")).to eq(
%(<a href="/watch?v=dQw4w9WgXcQ">Watch</a> this)
)
end
# Standard emojis are sent as an attachment, but the content string already
# contains the real unicode character, so the text must be left untouched.
# Captured from https://www.youtube.com/watch?v=Gy3bmuzmWMM
it "keeps standard emojis as unicode" do
desc = JSON.parse(<<-JSON)
{
"content": "everyone deserves to grow❤",
"attachmentRuns": [
{
"startIndex": 25,
"length": 1,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{
"url": "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.png",
"width": 16,
"height": 16
}
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq("everyone deserves to grow❤")
end
# Youtube inserts social media icons in descriptions with a length of 0,
# meaning no text is covered by the attachment.
# Captured from https://www.youtube.com/watch?v=Gy3bmuzmWMM
it "does not consume text for a zero length attachment" do
desc = JSON.parse(<<-JSON)
{
"content": "Follow me on twitter",
"attachmentRuns": [
{
"startIndex": 14,
"length": 0,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://www.gstatic.com/youtube/img/watch/social_media/twitter_1x_v2.png" }
]
}
}
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq("Follow me on twitter")
end
# Custom channel emojis have no unicode equivalent and are hosted on ggpht,
# so they must be rendered as a proxied image.
it "renders custom channel emojis as an image" do
desc = JSON.parse(<<-JSON)
{
"content": "nice video :_ToonThumbsUp:",
"attachmentRuns": [
{
"startIndex": 11,
"length": 15,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/abcdef=w24-h24-c-k-nd",
"width": 24,
"height": 24
}
]
}
}
},
"properties": {
"layoutProperties": {
"width": { "value": 24 },
"height": { "value": 24 }
},
"accessibilityProperties": { "label": "ToonThumbsUp" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq(
%(nice video <img alt="ToonThumbsUp" src="/ggpht/abcdef=w24-h24-c-k-nd" ) +
%(title="ToonThumbsUp" width="24" height="24" class="channel-emoji" />)
)
end
# Both kinds of run index into the same string and share a single iterator,
# so they have to be walked in order of appearance.
it "parses a description mixing a link and a custom emoji" do
desc = JSON.parse(<<-JSON)
{
"content": ":_Hi: watch",
"commandRuns": [
{
"startIndex": 6,
"length": 5,
"onTap": {
"innertubeCommand": {
"watchEndpoint": { "videoId": "dQw4w9WgXcQ" }
}
}
}
],
"attachmentRuns": [
{
"startIndex": 0,
"length": 5,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://yt3.ggpht.com/xyz=w24-h24-c-k-nd", "width": 24, "height": 24 }
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "Hi" }
}
}
}
]
}
JSON
expect(parse_description(desc, "someOtherId")).to eq(
%(<img alt="Hi" src="/ggpht/xyz=w24-h24-c-k-nd" title="Hi" width="24" height="24" class="channel-emoji" />) +
%( <a href="/watch?v=dQw4w9WgXcQ">watch</a>)
)
end
# Real capture of the comment linked in #5888, whose emojis are hosted on
# lh3.googleusercontent.com rather than ggpht:
# https://www.youtube.com/watch?v=Gy3bmuzmWMM&lc=UgxVdYO3R6wrr9sLhT54AaABAg
it "renders the emojis of the comment reported in #5888" do
desc = JSON.parse(<<-JSON)
{
"content": ":face-red-heart-shape:",
"attachmentRuns": [
{
"startIndex": 0,
"length": 22,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{
"url": "https://lh3.googleusercontent.com/I0Mem9dU=s16-w24-h24-c-k-nd",
"width": 16,
"height": 16
}
]
}
}
},
"properties": {
"layoutProperties": {
"height": { "value": 16 },
"width": { "value": 16 }
},
"accessibilityProperties": { "label": "face-red-heart-shape" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq(
%(<img alt="face-red-heart-shape" src="/ggpht/I0Mem9dU=s16-w24-h24-c-k-nd" ) +
%(title="face-red-heart-shape" width="16" height="16" class="channel-emoji" />)
)
end
it "does not treat a gstatic image as a custom emoji" do
desc = JSON.parse(<<-JSON)
{
"content": "hi",
"attachmentRuns": [
{
"startIndex": 0,
"length": 2,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://www.gstatic.com/youtube/img/watch/x.png", "width": 16, "height": 16 }
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "x" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq("hi")
end
# copy_string escapes the text as it copies it, so the fallback used when
# youtube sends no label must not be escaped a second time.
it "does not escape the fallback label twice" do
desc = JSON.parse(<<-JSON)
{
"content": "a&b",
"attachmentRuns": [
{
"startIndex": 0,
"length": 3,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://yt3.ggpht.com/x=w24-h24", "width": 24, "height": 24 }
]
}
}
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq(
%(<img alt="a&amp;b" src="/ggpht/x=w24-h24" title="a&amp;b" ) +
%(width="24" height="24" class="channel-emoji" />)
)
end
# URI#request_target returns the path untouched, quotes included, so it has
# to be escaped before being placed inside the src attribute.
it "escapes a quote in the image url" do
desc = JSON.parse(<<-JSON)
{
"content": "hi",
"attachmentRuns": [
{
"startIndex": 0,
"length": 2,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://yt3.ggpht.com/x\\"onerror=alert(1) a=\\"", "width": 24, "height": 24 }
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "e" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq(
%(<img alt="e" src="/ggpht/x&quot;onerror=alert(1) a=&quot;" title="e" ) +
%(width="24" height="24" class="channel-emoji" />)
)
end
# Matching the bare domain suffix would let a lookalike host through.
it "rejects a lookalike emoji host" do
desc = JSON.parse(<<-JSON)
{
"content": "hi",
"attachmentRuns": [
{
"startIndex": 0,
"length": 2,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://evilgoogleusercontent.com/x=w24-h24", "width": 24, "height": 24 }
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "e" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq("hi")
end
# A zero length attachment consumes no text, so a command starting at the
# same index must not advance the cursor past it and drop the image.
it "keeps a zero-length attachment sharing an index with a command" do
desc = JSON.parse(<<-JSON)
{
"content": "watch",
"commandRuns": [
{
"startIndex": 0,
"length": 5,
"onTap": {
"innertubeCommand": {
"watchEndpoint": { "videoId": "dQw4w9WgXcQ" }
}
}
}
],
"attachmentRuns": [
{
"startIndex": 0,
"length": 0,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://yt3.ggpht.com/z=w24-h24", "width": 24, "height": 24 }
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "e" }
}
}
}
]
}
JSON
expect(parse_description(desc, "someOtherId")).to eq(
%(<img alt="e" src="/ggpht/z=w24-h24" title="e" width="24" height="24" class="channel-emoji" />) +
%(<a href="/watch?v=dQw4w9WgXcQ">watch</a>)
)
end
# Youtube counts indexes in UTF-16 code units, so a single SMP codepoint
# placed before a run shifts every following index by two.
it "handles offsets after an SMP codepoint" do
desc = JSON.parse(<<-JSON)
{
"content": "\u{1F600} :_Test:",
"attachmentRuns": [
{
"startIndex": 3,
"length": 7,
"element": {
"type": {
"imageType": {
"image": {
"sources": [
{ "url": "https://yt3.ggpht.com/test=w24-h24-c-k-nd", "width": 24, "height": 24 }
]
}
}
},
"properties": {
"accessibilityProperties": { "label": "Test" }
}
}
}
]
}
JSON
expect(parse_description(desc, "")).to eq(
%(\u{1F600} <img alt="Test" src="/ggpht/test=w24-h24-c-k-nd" title="Test" width="24" height="24" class="channel-emoji" />)
)
end
end

View File

@ -1,3 +1,4 @@
require "html"
require "json"
require "uri"
@ -40,6 +41,69 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
return copied
end
# Google image CDNs that the /ggpht route can serve. They share a path
# namespace, so an emoji hosted on googleusercontent is also reachable through
# the ggpht pool that route proxies to.
private EMOJI_IMAGE_DOMAINS = {"ggpht.com", "googleusercontent.com"}
# Custom emojis (channel membership ones, and the ":face-red-heart-shape:"
# set) have no unicode equivalent, so youtube sends them as an image
# attachment covering the ":shortcut:" text, which has to be rendered as an
# <img> tag, otherwise the raw shortcut is shown to the user.
#
# Standard emojis are sent as attachments too, but the content string already
# holds the real unicode character, so those are left untouched: rendering
# them as images would only add pointless requests to youtube's servers.
#
# Custom emojis are hosted on google's image CDNs, which are all reachable
# through the /ggpht route, so anything hosted elsewhere (youtube.com and
# gstatic.com serve the standard emojis) is deliberately left as text.
private def parse_emoji_attachment(attachment : JSON::Any, text : String) : String
image = attachment.dig?("element", "type", "imageType", "image")
return text if image.nil?
source = image.dig?("sources", 0)
return text if source.nil?
url = source["url"]?.try &.as_s
return text if url.nil?
uri = URI.parse(url)
host = uri.host
return text if host.nil?
# Matching on the bare suffix would also accept "notggpht.com", so the host
# has to be the domain itself or one of its subdomains.
accepted = EMOJI_IMAGE_DOMAINS.any? do |domain|
host == domain || host.ends_with?(".#{domain}")
end
return text if !accepted
properties = attachment.dig?("element", "properties")
# The label is the emoji name, e.g "ToonThumbsUp" for ":_ToonThumbsUp:".
# `text` was produced by copy_string, which escapes as it copies, so it is
# only the raw label coming from youtube that still needs to be escaped.
raw_label = properties.try(&.dig?("accessibilityProperties", "label")).try(&.as_s)
label = raw_label.nil? ? text : HTML.escape(raw_label)
# Youtube gives the display size in "points" here, and the intrinsic size
# of the image in the source. The former is what the official client uses.
width = properties.try(&.dig?("layoutProperties", "width", "value")).try(&.as_i)
height = properties.try(&.dig?("layoutProperties", "height", "value")).try(&.as_i)
width ||= source["width"]?.try &.as_i
height ||= source["height"]?.try &.as_i
return String.build do |str|
str << %(<img alt=") << label << "\" "
str << %(src="/ggpht) << HTML.escape(uri.request_target) << "\" "
str << %(title=") << label << "\" "
str << %(width=") << (width || 24) << "\" "
str << %(height=") << (height || 24) << "\" "
str << %(class="channel-emoji" />)
end
end
def parse_description(desc, video_id : String) : String?
return "" if desc.nil?
@ -47,7 +111,9 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a
if commands.nil?
attachments = desc["attachmentRuns"]?.try &.as_a
if commands.nil? && attachments.nil?
# Slightly faster than HTML.escape, as we're only doing one pass on
# the string instead of five for the standard library
return String.build do |str|
@ -56,6 +122,18 @@ def parse_description(desc, video_id : String) : String?
end
end
# Commands (links) and attachments (emojis) both index into the same string
# and are consumed by the same iterator below, so they have to be walked in
# a single pass, ordered by their position in the text.
# Runs of length 0 consume nothing and come first, otherwise a command
# sharing their index would advance past them and they'd be dropped by the
# overlap check. Commands then come before attachments, as Crystal's sort is
# not guaranteed to be stable.
runs = [] of Tuple(Int32, Int32, Bool, JSON::Any)
commands.try &.each { |cmd| runs << {cmd["startIndex"].as_i, cmd["length"].as_i, false, cmd} }
attachments.try &.each { |att| runs << {att["startIndex"].as_i, att["length"].as_i, true, att} }
runs.sort_by! { |run| {run[0], run[1] == 0 ? 0 : 1, run[2] ? 1 : 0} }
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
# automatically decoded by the JSON parser. It means that we need to count
@ -65,29 +143,39 @@ def parse_description(desc, video_id : String) : String?
index = 0
return String.build do |str|
commands.each do |command|
cmd_start = command["startIndex"].as_i
cmd_length = command["length"].as_i
runs.each do |(run_start, run_length, is_attachment, node)|
# Overlapping runs would desynchronize the iterator from the index, so
# anything starting inside an already consumed run is skipped.
next if run_start < index
# Copy the text chunk between this command and the previous if needed.
length = cmd_start - index
# Copy the text chunk between this run and the previous if needed.
length = run_start - index
index += copy_string(str, iter, length)
# We need to copy the command's text using the iterator
# We need to copy the run's text using the iterator
# and the special function defined above.
cmd_content = String.build(cmd_length) do |str2|
copy_string(str2, iter, cmd_length)
# Attachments may have a length of 0, when youtube inserts an image
# without any text behind it, in which case nothing is consumed.
run_content = if run_length > 0
String.build(run_length) { |str2| copy_string(str2, iter, run_length) }
else
""
end
if is_attachment
str << parse_emoji_attachment(node, run_content)
else
link = run_content
if on_tap = node.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, run_content, video_id)
end
str << link
end
link = cmd_content
if on_tap = command.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, cmd_content, video_id)
end
str << link
index += cmd_length
index += run_length
end
# Copy the end of the string (past the last command).
# Copy the end of the string (past the last run).
content_size = content.ascii_only? ? content.size : utf16_length(content)
remaining_length = content_size - index
copy_string(str, iter, remaining_length) if remaining_length > 0