Sahi 案例分享:音乐批量下载

阅读数:2507 2012 年 6 月 13 日

话题:测试DevOps语言 & 开发架构

Sahi 案例分享:音乐批量下载

本文将要向大家分享一个音乐批量下载的脚本。它使用了 Shell 脚本和 Sahi 脚本。该实例向大家展示了 Sahi 在除了 Web UI 自动化测试以外的一个实际应用。在阅读本文前,如果您还不知道 Sahi 是什么,建议您可以先阅读一下我的另一篇文章《使用 Sahi 测试 Dojo 应用》,也可以直接访问 [Sahi 的官方网站](http://sahi.co.in/w/)。另外,您最好对 Shell 编程以及 Linux 中的 sed,grep 以及 awk 等命令能有一定的概念。

1 背景介绍

偶然的机会,我发现了 gmbox (http://code.google.com/p/gmbox/)。它是一款用 Python 编写的开源软件,放在 Google Code 上,可以提供音乐的批量下载。但是,用了不久,就发现它无法下载音乐了。在浏览了 gmbox 的 Wiki 之后,发现原因是这样的:针对每首歌曲,gmbox 最终是得到并发送这样的一个 URLhttp://www.google.cn/music/songstreaming?id=Sb085ad586f0447da&cad=localUser_player&cd&sig=87cd90dde819885241640b3d9cc7271a&output=xml 并从返回的 XML 中得到 mp3 的下载链接、歌词以及专辑封面的下载链接的。这个 URL 中有一个参数叫 sid,很显然它是一串经过加密的字符串。通过阅读 gmbox 的源码,不难发现它的生成规则。


flashplayer_key = "a3230bc2ef1939edabc39ddd03009439"
sig = hashlib.md5(flashplayer_key + self.id).hexdigest()

问题出现在 flashplayer_key 上。谷歌每过一段时间就会修改这个 flashplayer_key 的值,于是导致 gmbox 相关的功能无法工作(比如试听音乐下载以及歌词和专辑封面下载)。

2 核心原理

如果我们用 Sahi 来模拟用户搜索音乐、播放音乐的操作并使用某种机制记录下所有的获取音乐信息 URL,然后再用 wget 或者 curl 之类的命令进行下载不就可以绕过这个 flashplayer_key 的问题了吗?这就是我的解决方案。

为了完成整个的过程,让我们一起看看有哪些关键步骤以及响应的结局方案。

  1. 自动启动 Sahi(如果它没有启动的话)。 Sahi 默认启动在端口 9999,通过 lsof 就能够知道是否 Sahi 已启动。如果启动的话,就 Kill 掉它,然后重新启动 Sahi。
  2. 通过 Sahi 脚本在浏览器中模拟用户搜索播放音乐的操作。 这当中碰到的一个问题是如何将搜索关键字作为参数传递个 Sahi 的脚本。最后,我是用文件的方式传递的。
  3. 记录下所有获取音乐信息的 URL 到一个文件中。 起初,我准备用 dsniff 记录 URL,但很快我意识到这不是一个好办法,因为用户不得不额外安装 dsniff。 后来,我发现 Sahi 通过启用一个调试选项,能够记录所有 HTTP(S)请求响应通过 Sahi 代理服务器时被修改前和修改后的内容(traffic log)。这仅需要用户在配置文件中添加一个设置。因此,我选用了这种方式。
  4. 通过 wget 或者 curl 从文件中读取 URL 并下载。

最后,我用了 wget。

想要具体了解实现的细节,请阅读“代码详解”部分。

3 使用方法

在开始讲解代码之前,先让我们来看看如何运行这个脚本。

  1. 下载及安装 Sahi(http://sahi.co.in/w/using-sahi)。
  2. 把 googlemusic.zip 解压缩到你喜欢任意目录下。
  3. 设置环境变量 SAHI_HOME 指向你的 SAHI 安装目录。
  4. 在 $SAHI_HOME/userdata/config/userdata.properties 中添加如下代码行,用来启用 Sahi 调试的 traffic log。 debug.traffic.log.unmodified=true
  5. 在 $SAHI_HOME/userdata/scripts 目录下建立一个软链接到解压缩的目录。假设你把压缩包解压到你的 home 目录下,就运行“ln sf ~/googlemusic/”。另一种方法是直接把压缩包解压到 $SAHI_HOME/userdata/scripts 下,这样,就省去创建软链接的步骤。
  6. 脚本中默认指定的 browser 名称是“chrome”。如果你没有安装 Chrome 或者其 name 属性不是“chrome”的话,需要修改 googlemusic.sh 中的 browser 变量为相应的值。
  7. 进入你想要保存音乐文件的目录,运行“~/googlemusic/googlemusic.sh “阿黛尔 (Adele)"”(假设脚本解压缩在你的 home 目录下)。如果需要下载某位歌手的全部歌曲,为了确保最后下载歌曲的准确性,最好先到谷歌音乐上去尝试着搜索一下。例如,你如果在 http://www.google.cn/music/homepage 中输入“阿黛尔 (Adele)”,就会发现搜索结果只包含阿黛尔的歌曲。但你若输入“Adele”,结果就相当混乱。所以,应该使用“阿黛尔 (Adele)”。

如果一切正常,在执行完以上的步骤之后,脚本会自动启动 Sahi 代理服务器。然后,弹出谷歌音乐的在线播放器页面,执行搜索。接着,会看到歌曲一首一首被“播放”。所谓“播放”,只是让浏览器发送获取歌曲的 URL 从而使 Sahi 记录下来。直到最后一首歌曲被“播放”完,Sahi 代理服务器自动关闭。之后,wget 开始下载 mp3 文件和 lrc 文件(如果有的话)。所有 mp3 以及歌词文件会被下载到以搜索关键字命名的目录下。为了能够在未下载完的情况下,下次仍能继续下载而无需启动 Sahi 重新获取 URL,所有获取歌曲信息的 URL 被保存在当前目录下的 songs.txt 中。如果需要,你可以运行“~/googlemusic/download.sh songs.txt “阿黛尔 (Adele)"”继续之前未完的下载。当中,第二个参数是下载目录,download 会在下载前检查文件是否已经存在,如果存在就跳过。因此,你如果想完全重新下载所有歌曲,指定一个新的目录。

4 代码详解

googlemusic.zip 中包含了三个脚本文件。

  1. googlemusic.sh: 负责自动启动关闭 Sahi 代理服务器;运行 googlemusic.sah 之后解析 traffic log 文件并将结果保存到 songs.txt 文件;最后调用 download.sh 执行下载。
  2. download.sh: 执行 mp3 与歌词文件下载。
  3. googlemusic.sah: 模拟用户在谷歌音乐在线播放器中的搜索播放操作。

googlemusic.sh 代码讲解

googlemusic.sh 的工作流程如下: 1. 检查环境变量 SAHI_HOME 是否设置。如果没有,退出程序。 2. 清理之前运行的残余文件(包括 songs.txt,keyword 文件以及 Sahi 的 traffic log 目录)。 3. 判断是否已经有 Sahi 的进程在运行,如果有,就 Kill 掉。 判断的方法是通过 lsof 检查运行在端口 9999 上进程(Sahi 服务器默认的端口是 9999)。结合 grep 和 awk 命令获取 pid 并 kill 掉该进程。 4. 启动 Sahi 服务器,并把进程 pid 保存到变量 sahi_pid 中。 获取 sahi_pid 的方法与上相同。 5. 执行 googlemusic.sah 脚本。

谷歌在线播放器的 URL 事实上是 http://g.top100.cn/16667639/html/player.html#loaded ,而脚本使用的却是 http://www.google.cn/music/top100/player_page,为什么?这是尝试的结果。第一次打开在线播放器页面的时候,会出现一个“服务条款”页面问你是否同意。在使用播放前默认的 URL 的时候,Sahi 无法点击到“同意”按钮。通过 Chrome 的 Developer Tools,我发现该页面有很多 iframe 构成,于是开始尝试用 http://www.google.cn/music/player,这是它最内层真正显示播放器的 iframe URL。这次,Sahi 成功地点击了“同意”按钮进入播放器界面。 

 6. Kill Sahi 进程(PID 记录在变量 sahi_pid 中)。 7. 从 traffic log 文件里提取音乐信息 URL 保存到 songs.txt 文件中。 这段代码结合了 grep 和 awk 两个命令并把结果导出到 songs.txt 文件中。 8)执行 download.sh 下载 mp3 及歌词文件。

download.sh 代码讲解

下面这个 URL 是 songs.txt 中的一行。


http://www.google.cn/music/songstreaming?id=S82816ab0c2814785&cad=localUser_player&cd&sig=1ccf866dca1cdc1853fb921e01f0438a&output=xml

脚本中通过 curl 命令得到如下请求结果:

<results>
<songStreaming>
<id>S82816ab0c2814785
<songUrl>
http://audio2.top100.cn/201205262149/63C7DB9ECEC3287F9F6DB0423C4F81F9/streaming1/Special_101259/M0101259012.mp3
</songUrl>
<lyricsUrl>
http://lyric.top100.cn/Special_101259/M0101259012.lrc
</lyricsUrl>
<albumThumbnailLink>
http://lh6.googleusercontent.com/public/_AMNePcsm5yfz_WxM_iSmJUvYa0aIyP7W5bTih_z5XmDOGmfA7SpTh3gdPe2tDFqk2x5rGzr1pToVWmPpnjzucip7WCxoS35zqEQvFtprb-cUPk_e3Sd0fUXYFT0aXW7oqUR
</albumThumbnailLink>
<label> 索尼音乐娱乐(中国)
<labelHash>ca646574dfb918889fcc1ed02d933f6c
<providerId>M0101259012
<artistId>A37ef8fc531bca276
<language>en
<genre>rnb
<genre>pop
</songStreaming>
</results>

可以看出,songUrl 节点的值就是 mp3 的下载地址。我们还需要知道歌曲名和歌手名,把它们拼接成 mp3 文件名。所以又用 curl 命令请求了另一个 URL"http://www.google.cn/music/song?id=$songId&output=xml", $songId 正是上面 XML 内容中的 id 节点值(S82816ab0c2814785)。返回的 XML 内容如下。artist 以及 name 节点正是歌手名和歌曲名。

<results>
<songList>
<!-- freemusic/song/result/S82816ab0c2814785 -->
<song>
<id>S82816ab0c2814785
<name>Who Is It
<artist> 迈克尔 杰克逊 (Michael Jackson)
<artistId>A37ef8fc531bca276
<album>King Of Pop CD1
<duration>241.0
<canBeDownloaded>true
<hasFullLyrics>true
<canBeStreamed>true
<albumId>B8cacd47437481c83
<hasSimilarSongs>true
<hasRecommendation>false
</song>
</songList>
<estimatedResultCount>1
</results>

readXmlAttr() 函数用来从指定的 XML 文档中读取指定的节点的值。方法是用 sed 命令把“</”全部替换成“<”后再用“awk -F”把指定的节点标签作为分割符对文档进行分割并取出第 2 个元素值。

googlemusic.sah 代码讲解

googlemusic.sah 脚本的运行过程如下:

1 检查有没有文本是“同意”的 div,如果有,就点击,没有,便跳过(“服务条款”被“同意”过一次之后,就不会再出现,直到你清楚浏览器的 Cookie)。

2 如果之前你有在播放器里搜索过歌曲,当再次打开播放器时,哪些歌曲仍会显示在歌曲列表中。因此,需要先清除所有已有的歌曲。每首歌曲都会在一个 class 为"artist-cell"的 td 里显示歌手名称,统计这类 td 的数目就可以知道有多少首歌曲当前显示在列表里。如果 $count 的值大于 0 就说明有歌曲显示。接着就是点击“全选”checkbox。这里用的是near 函数,还有一种方法是"checkbox(count(“checkbox”,‘’)-1)“,也就是得到最后一个 checkbox,这个 checkbox 就是“全选”。最后点“删除”按钮并在跳出的确认对话框里点“Yes”(既然是简体中文网页,谷歌事实上应该显示“是”)。


var $count=_count('_cell','artist-cell')
if($count>0){
    _click(_checkbox(0,_near(_span("全选")))) 
    _click(_div("删除"))
    _click(_submit("Yes"))
}

3 搜索歌曲。.sah 文件在被调用时无法传参数,所以 keyword 是通过一个文本文件中转的。

var $keyword=_readFile("/tmp/googlemusic_keyword.txt")

谷歌音乐搜索结果的显示是以一种增量的方式进行的,默认它会显示 20 首歌曲,然后随着你向下拖动,它会显示更多,直到最后出现“已经到达最后一条搜索结果”。所以 Sahi 脚本也必须模拟这种“滚动”操作来显示出所有结果。结束的标志就是看页面上是否出现了“_div(“已经到达最后一条搜索结果”)”这个元素。通过重新设定歌曲列表 div 的 scrollTop 属性可以实现“滚动”操作。根据 Sahi 脚本的编写要求,这类操作必须放在 browser 标签中 – 这就是 scrollOnce 函数。

    
var $done=false;
while(!$done){
   _set($test,scrollOnce());
   $done=_isVisible(_div("已经到达最后一条搜索结果"));
}

<browser>
function scrollOnce(){
    var $list=_div("list-content");
    $list.scrollTop=$list.scrollHeight;
}
</browser>

4 “播放”所有歌曲。前面已经讲过,所谓“播放”只是为了发送音乐信息 URL 的请求以便 Sahi 把 URL 记录下来。首先,通过计算 class 为 artist-cell 的 td 的数目得到歌曲总数,然后对“下一首”按钮(id 为“:4”的 div)进行相应次数的点击。

var $count=_count('_cell','artist-cell')
_log($count,'custom1')
while($count>0){
   _click(_div(':4'))
   $count=$count-1
}

后记

整个脚本的开发是一个不断尝试和探索的过程。最初,部分的代码是用 Python 写的,但这样无形中增加了额外的依赖性,于是我把 Python 实现的逻辑用 Shell 进行了重写。用 Sahi 脚本下载谷歌音乐显然不是最好的解决方案,因为它存在一些用户体验的问题。第一,有浏览器窗口弹出;第二,当歌曲数量较多时,逐一地点击歌曲生成歌曲 URL 并记录的方式通常会花费较长时间(与当时的网络速度也存在一定关系)。关于第一个问题,通过 Xvfb 可以不弹出浏览器窗口(http://sahi.co.in/w/configuring-sahi-with-xvbf)。关于第二个问题,我曾经试图直接在 DOM 树上直接拿到歌曲的 URL,但最终没有找到。有兴趣的读者可以研究一下。无论如何,对于学习 Sahi 来讲,这个脚本仍是一个很好的实践,而且,它说明了 Web UI 自动化测试不是 Sahi 唯一的用武之地。

另外,这只是一个用来学习交流的脚本,没有经过充分地测试。所以,如果在运行中出现问题,敬请谅解。

免责声明:本文涉及的代码仅供学习 Sahi 使用,请不要用来进行制作盗版音乐等非法行为,如果发生类似情况,InfoQ 中文站与作者均不负责。

代码下载

关于作者

沈锐,目前从事 Web UI 功能测试工作,对 Web UI 自动化测试有着浓厚的兴趣。


给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。