大约一年前,我开始开发YoutubeExplde,这是一个可以删除YouTube视频上的信息并让你下载它们的库。最初,我开发它的主要动机只是为了获得经验,因为这项任务涉及到大量的研究和逆向工程。如今,YoutubeExplde可以说是处理YouTube的最一致、最健壮的.NET库。
由于这在许多新手开发人员中是一个相对流行的讨论话题,我想我可以通过花几十个小时盯着Chrome开发人员工具来分享我找到的知识,从而帮助解决这个问题。
注:尽管这里解释的基本原则不太可能改变,但这篇文章中的一些信息可能会过时。这篇文章与YoutubeExplde v4.1(2018年2月16日)相关。
为了查找和解析媒体流,您需要首先获取视频元数据。有几种方法可以做到这一点,但最可靠的方法是查询YouTube的iframe embed API内部使用的Ajax端点。格式如下:https://www.youtube.com/get_video_info?video_id={videoId}.。
该请求可以接受许多不同的参数,但至少需要一个视频ID-/watch?v=之后的URL中的值,例如e_S9VvJM1PI。
响应包含URL编码的元数据,必须先对其进行解码才能使用。之后,您可以将参数名称映射到字典中的值,以便于访问。某些参数值本身就是嵌套对象,因此可以依次将它们映射到嵌套字典。
如您所见,有相当多的信息可以立即提取。
我们还来看一下此请求可以采用的一些重要可选参数:
HL-用于本地化某些字符串的区域性的名称。如果未设置,则默认为从您的IP推断的区域性。使用hl=en在所有字符串上强制使用英语。
发出请求的YouTube页面的EL类型。这决定了响应中将提供什么类型的信息。在某些情况下,您需要根据视频类型将此参数设置为某个值,以避免错误。默认为嵌入式。
STS-某种会话标识符,用于在请求之间同步信息。默认为空。
El请求参数可以接受多个值,它会影响您将接收作为响应的数据类型。不过,真正重要的只有几个,所以我将在这里列出它们:
Embedded,默认值。YouTube在请求嵌入视频的信息时使用此功能。不适用于不可嵌入的视频,但适用于年龄限制的视频。
替代值Detail Page包含更多信息。相反,适用于不可嵌入的视频,但不适用于年龄限制的视频。
YoutubeExplde对第一个查询使用el=Embedded。如果由于无法嵌入视频而失败,则会使用el=DetailPage重试。
错误代码似乎非常通用,大多数情况下不是100就是150,因此它们在确定哪里出了问题时用处不大。
有些视频需要购买后才能观看。在这些情况下,将会有:
多路复用(多路复用)流是在同一流中同时包含视频和音频轨道的类型。YouTube只提供低质量的流媒体--最好的是720p30。
这些流的元数据包含在前面提到的url_encode_fmt_stream_map参数内的URL编码响应中。要提取它,您只需将值拆分,然后对每个部分进行URL解码。
注意:我遇到过这样的情况,一些多路数据流尽管仍出现在元数据中,但仍被删除。因此,建议发送Head请求,检查每条流是否仍然可用。您还可以在查看内容时获取内容长度,因为它不存在于元数据中。
YouTube还使用纯视频流和纯音频流。这些产品具有最高的可用质量,没有任何限制。
类似于多路复用的流,可以从Adaptive_fmts参数中提取这些流的元数据。下面是它的外观:
自适应流具有略微扩展的一组属性。我将列出一些有用的信息:
视频信息可能在dashmpd参数中包含破折号清单的URL。它并不总是存在,有些视频可能根本就没有它。
要解析这些流的元数据,您需要首先使用提供的URL下载清单。有时货单可以受到保护。如果是,您应该能够在URL中找到签名-它是/s/后面用斜杠分隔的值。
DASH中的流也可以分段-每个分段从给定点开始,仅持续一到两秒。这是您的浏览器在YouTube上播放视频时通常使用的类型-它使其可以根据网络条件轻松调整质量。分段流也用于直播视频。不过,这篇文章不会涉及它们,因为下载视频不需要对它们进行处理。
破折号清单遵循此XML模式。如果遍历类型表示的所有子代节点,则可以解析流元数据。下面是它们的显示方式:
Https://r12---sn-3c27sn7k.googlevideo.com/videoplayback?id=7bf4bd56f24cd4f2 ITAG=133source=Youtube requressl=yes ei=Bt4VWqLOJMT3NI3qjPgB ms=au gcr=ua mv=mpl=24 mn=sn-3c27sn7k initcwndbps=1143750 mm=31 nh=IgpwcjAxLmticDAxKgkxMjcuMC4wLjE速率绕过=yes MIME=视频/MP4gir=yes clen=4436318 lmt=1507180947831345 dur=183.850 mt=1511382418密钥=dg_yt0 s=7227CB6B79F7C702BB11275F9D71C532EB7E72046.DD6F06570E470E0E8384F74B879F79475D023A64A64 signature=254E9E06DF034BC66D29B39523F84B33D5940EE3.1F4C8A5645075A228BB0C2D87F71477F6ABFFA99 ip=255.255.255.255 ipbit=0=1511404params=ip,ipbit,expire,id,itag,source,source,ressl,ms,gcr,mv,pl,mn,initcitcndbps,mm,mm,nh,旁路,MIMIR,gir,clen,dur=183.850 mt=1511382418密钥=dg_yt0密钥=255.255.255.255 ipbit=0=151144params=ip,ipbit,过期,id,itag,源,请求ressl,mm,ms,gcr,mv,pl,mn,initcitcnbps,mm,mm,nh,旁路,MIMIR,gir,clen,dur=183.850 mt=1511382418。
注意:不要试图从contentLength属性中提取内容长度,因为它并不总是出现在<;BaseURL>;标记上。相反,您可以使用正则表达式根据URL中clen查询参数对其进行解析。
您可能会注意到,有些视频(主要是通过验证频道上传的视频)是受保护的。这意味着它们的媒体流和破折号清单不能通过URL直接访问-而是返回403错误代码。为了能够访问它们,您需要解密它们的签名,然后适当地修改URL。
对于多路复用和自适应流,签名是提取的元数据的一部分。仪表流本身永远不会受到保护,但实际的清单可能会受到保护-签名作为URL的一部分存储。
签名是由两个由句点分隔的大写字母和数字序列组成的字符串。这里有一个例子:537513BBC517D8643EBF25887256DAACD7521090.AE6A48F177E7B0E8CD85D077E5170BFD83BEDE6BE6C6C.
当您的浏览器打开YouTube视频时,它会使用播放器源代码中定义的一组操作来转换这些签名,并将结果作为附加参数放入URL中。要在代码中重复相同的过程,您需要找到视频使用的播放器的JavaScript源并对其进行解析。
每个视频使用的播放器版本略有不同,这意味着您需要确定下载哪个版本。如果您获得视频嵌入页面的HTML,您可以搜索";js";:来查找包含播放器相对源代码URL的JSON属性。一旦你为youtube的主机添加了前缀,你最终会得到一个类似这样的网址:https://www.youtube.com/yts/jsbin/player-vflYXLM5n/en_US/base.js.。
除了获取播放器源URL之外,您还需要获取名为STS的东西,它看起来像是某种会话令牌。您需要将其作为参数发送到前面提到的GET_VIDEO_INFO端点-这将确保返回的元数据对于该播放器上下文有效。您可以类似地提取sts的值,只需搜索";sts";:就可以找到它。
找到源代码URL并下载后,需要对其进行解析。有几种方法可以做到这一点,为了简单起见,我选择使用正则表达式来解析它。
我不会一步一步地解释您到底需要做什么,而只是从YoutubeExplde复制一小部分源代码。我一定会尽我所能对它进行评论,所以它应该很容易理解。
私有异步GetCipherOperationsAsync(SourceUrl){//获取播放器源代码sourceRaw=await_httpClient。GetStringAsync(SourceUrl);//找到处理解密Entry Point=Regex的函数名称。Match(SourceRaw,@";\bc\s*&;&;\s*d\.Set\([^,]+,\s*(?:encodeURIComponent\s*\()?\s*([\w$]+)\(";).Groups[1].value;if(String.。IsNullOrWhiteSpace(入口点)抛出new(";找不到用于签名解密的入口函数。";);//找到函数entryPointBody=Regex的正文。Match(sourceRaw,@";(?!h\.)";+Regex.。转义(入口点)+@";=函数\(\w+\)\{(.*?)\}";,RegexOptions.Singleline).Groups[1].value;if(String.。IsNullOrWhiteSpace(EntryPointBody)引发new(";找不到签名解密器函数体。";);entryPointLines=entryPointBody。Split(";;";);//识别密码函数verseFuncName=NULL;sliceFuncName=NULL;charSwapFuncName=NULL;Operations=new();//分析函数体以确定密码函数的名称foreach(entryPointLines中的行){//如果(!字符串。IsNullOrWhiteSpace(ReverseFuncName)&;&;!字符串。IsNullOrWhiteSpace(SliceFuncName)&;&;!字符串。IsNullOrWhiteSpace(CharSwapFuncName))Break;//获取此行调用的函数,名为FuncName=Regex。Match(line,@";\w+\.(\W+)\(";).Groups[1].value;if(String.。IsNullOrWhiteSpace(CalledFuncName))Continue;//查找密码函数名称if(regex.。IsMatch(sourceRaw,$@";{regex.。转义(CalledFuncName)}:\bfunction\b\(\w+\)";)){verse seFuncName=calledFuncName;}Else if(Regex.。IsMatch(sourceRaw,$@";{regex.。转义(CalledFuncName)}:\bfunction\b\([a],b\).(\breTurn\b)?.\W+\.";){sliceFuncName=calledFuncName;}Else if(Regex.。IsMatch(sourceRaw,$@";{regex.。ESCAPE(CalledFuncName)}:\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b";){charSwapFuncName=calledFuncName;}}//再次分析函数体以确定操作集并对foreach排序(entryPointLines中的行){//获取此行调用的名为dFuncName=regex的函数。Match(line,@";\w+\.(\W+)\(";).Groups[1].value;if(String.。IsNullOrWhiteSpace(CalledFuncName))继续;//如果(calledFuncName==charSwapFuncName){index=int.。Parse(Regex.。匹配(line,@";\(\w+,(\d+)\)";).Groups[1].value);操作。Add(new(Index));}//切片操作(返回索引处的子字符串),否则if(calledFuncName==sliceFuncName){index=int。Parse(Regex.。匹配(line,@";\(\w+,(\d+)\)";).Groups[1].value);操作。Add(new(Index));}//反转操作(反转整个字符串),否则if(calledFuncName==verse seFuncName){操作。新增(new());}}退货操作;}。
此方法的输出是ICipherOperations的集合。此时,最多可以有3种密码操作:
成功提取所用操作的类型和顺序后,需要将它们存储在某个位置,以便可以在签名上执行它们。
解析播放器源代码后,您可以获得解密后的签名,并相应更新URL。
对于多路复用和自适应流,转换从元数据提取的签名并将其添加为名为Signature-...&;signature=212CD2793C2E9224A40014A56BB8189AF3D591E3.523508F8A49EC4A3425C6E4484EF9F59FBEF9066的查询参数。
对于破折号清单,转换从url中提取的签名并将其添加为名为Signature-.../signature/212CD2793C2E9224A40014A56BB8189AF3D591E3.523508F8A49EC4A3425C6E4484EF9F59FBEF9066/的路由参数。
每个媒体流都有唯一标识其属性(如容器类型、编解码器、视频质量)的ITAG。YoutubeExplde使用已知标签的预定义映射解析这些属性:
私有静态只读ItagMap=new{//多路复用{5,new(Container.Flv,AudioEncoding.mp3,VideoEncoding.H263,VideoQuality.Low144)},{6,new(Container.Flv,AudioEncoding.mp3,VideoEncoding.H263,VideoQuality.Low240)},{13,new(Container.Tgpp,AudioEncoding.Aac,VideoEncoding.Mp4V,VideoQuality.Low144)},{17,new(Container.Tgpp,AudioEncoding.Aac,VideoEncoding.Low144)}。New(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium360)},{22,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High720)},{34,new(容器.Flv,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium360)},{35,new(Container.Flv,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium480)},{36,new(Container.Tgpp,Aac,Aac,VideoEncoding.Mp4V)},{35,new(Container.Flv,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium480)},{36,new(Container.Tgpp,AudioEncoding.Aac,VideoEncoding.Mp4V。VideoQuality.Low240)},{37,new(Container.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High1080)},{38,new(Container.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High3072)},{43,new(Container.WebM,AudioEncoding.Vorbis,VideoEncoding.Medium360)},{44,new(Container.WebM,AudioEncoding.Vorbis,VideoEncoding.Vp8,VideoQuality.Medium480)},{45,new(Container.WebM,AudioEncoding.Vorbis,VideoQuality.Medium360)},{44,new(Container.WebM,AudioEncoding.Vorbis,VideoEncoding.Vp8,VideoQuality.Medium480)},{45,new(Container.WebM,AudioEncoding.Vorbis,VideoQuality.Medium360)}。AudioEncoding.Vorbis,VideoEncoding.Vp8,VideoQuality.High720)},{46,new(Container.WebM,AudioEncoding.Vorbis,VideoEncoding.Vp8,VideoQuality.High1080)},{59,new(Container.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium480)},{78,new(Container.mp4,AudioEncoding.H264,VideoQuality.Medium480)},{82,new(Container.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium360)},{83,VideoEncoding.H264,VideoQuality.Medium480)。New(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium480)},{84,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High720)},{85,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High1080)},{91,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Low144)},{92,new(Container.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High1080)},{91,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Low144)},{92,new(Container.mp4,AudioEncoding.Aac,VideoEncoding.High1080)}。VideoQuality.Low240)},{93,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Medium360)},{94,new(容器.mp4,AudioEncoding.Aac,VideoQuality.Medium480)},{95,new(容器.mp4,AudioEncoding.H264,VideoQuality.High720)},{96,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High1080)},{100,new(容器.WebM,M,High1080)},{95,new(容器.mp4,AudioEncoding.H264,VideoQuality.High720)},{96,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.High1080)。AudioEncoding.Vorbis,VideoEncoding.Vp8,VideoQuality.Medium360)},{101,new(容器.WebM,AudioEncoding.Vorbis,VideoEncoding.Vp8,VideoQuality.Medium480)},{102,new(容器.WebM,AudioEncoding.Vp8,VideoQuality.High720)},{132,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Lowum240)},{151,new(容器.mp4,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Low144)},{151,new(容器.WebM,AudioEncoding.Aac,VideoEncoding.H264,VideoQuality.Low144)},{132,new(容器.WebM,AudioEncoding.Vp8,VideoQuality.Medium360)},{101,new(容器.WebM,AudioEncoding.Vp8,VideoQuality.Medium480)},{102。//纯视频(Mp4){133,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.Low240)},{134,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.Medium360)},{135,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.Medium480)},{136,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.High720)},{137,new(Container.mp4,null,VideoQuality.High720)}。VideoEncoding.H264,VideoQuality.High1080)},{138,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.High4320)},{160,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.Low144)},{212,new(Container.mp4,null,VideoEncoding.H264,VideoQuality.Medium480)},{213,new(Container.mp4,null,V