学校新开了口语课,要求在家把课文什么的背诵时录音,上传到喜马拉雅平台,老师通过订阅学生,收听音频方式收作业。前两天老师说要收集班上所有人的音频文件,但是喜马拉雅音频手机中可以下载但是不能导出,电脑上web版本更是没有直接下载的链接,我在网上搜索时无意中搜到了通过喜马拉雅音频ID获取音频文件地址的官方API,而且API没有任何认证可以直接调用,于是我就想利用我会的这一点点编程语言尝试着写一个快速的小程序来做到直接下载。(音频ID同样出现在每个音频页面的URL中。)

Javascript版 - 失败

由于我目前掌握的相对好一点的就是JS和PHP,为了后面能够方便设计界面我优先选择的是JS。网页方面用的就是我之前自己写的KyMD作为样式,虽然简陋但是够用。网页很简单,就加了一个输入框,一个按钮还有一个文本域用于存放结果。

为了能够确认输入的是我需要的音频页面链接,我首先对传入的URL进行判断。

function checkIsTrackUrl(url){
    if(!url) return false;
    var a = document.createElement("a");
    a.href = url;
    if(!a.hostname=="www.ximalaya.com") return false;
    if(a.pathname.indexOf("/sound/")===-1) return false;
    var start = a.pathname.indexOf("/sound/")+7;
    var track = a.pathname.slice(start).replace(/\//,"");
    return track;
}

如果为空直接返回false。
喜马拉雅音频页面的链接结构大概是 http://www.ximalaya.com/XXXXXXXX/sound/XXXXXXXX/
最后那一串XXXXXXXX便是我需要的音频ID。
然后创建一个a元素,把url穿进去,这样就可以直接获取到url的各部分结构了。先查域名是不是喜马拉雅的域名,如果不是返回false,然后判断路径中是否存在 /sound/ 这段字符,如果出现则代表是喜马拉雅音频的链接,否则其他的专辑页面啊什么的全都返回false。
最后,取出 /sound 后面的字符。我先获取到 /sound/ 的字符位置,然后+7跳过 /sound/,这样就能获取到最后的几位数字了。为了确保链接最后不会多出一个路径符号,我这里再次的替换一次,确保取出来的只是音频ID不是其他的什么东西。

之后就可以进入另一个函数了

function getTrackDownUrl(url){
    var track = checkIsTrackUrl(url);
    if(track===false){
        dialog.setTitle("Track 地址无效").setContent("请输入正确的Track地址! (地址格式验证不通过,请确保地址全部小写且合法)").show();
        return false;
    }
    console.debug("Track ID Get: "+track);
    var apiPath = "http://www.ximalaya.com/tracks/"+track+".json";
    $.getJSON(apiPath,function (r,s){
        if(s!==200){
            dialog.setTitle("获取信息时出现错误").setContent("获取信息时出现错误: HTTP "+s+" ERROR").show();
            return;
        }
        var id = r.id,
            download = r.play_path_64,
            duration = r.duration,
            title = r.title,
            user = r.nickname,
            album = r.album_title,
            info = r.intro,
            time = r.time_until_now;
        var audiolength = duration / 60;
        dialog.setTitle("来自"+user+"的音频").setContent(
            "<b>音频:</b> "+title +"<br>"+
            "<b>长度:</b> "+audiolength+"<br>"+
            "<b>专辑:</b> "+album+"<br>"+
            "<b>上传时间:</b> "+time+"<br>"+
            "<b>介绍:</b> "+info+"<br>"+
            "<b>下载地址:</b> "+download+"<br>"
        ).show();
        document.getElementById("result").value = download;
    });
}

先判断 checkIsTrackUrl 函数的返回,若是false则url没通过检查,直接弹出错误然后返回。这里 dialog 是我二次封装的一个 dialog-polyfill 对话框。这些代码都在我的 KyMD 中。
之后拼接api地址,使用jquery的获取json方式获得结果。

PHP版 成功

如上方法在实际测试时,发现出现了跨站的问题,所以就只能舍弃用户界面,换成php语言重写成命令行程序。
我直接使用了Minecraft游戏服务器的 PHP7 运行环境,无需配置就可以使用。
由于是命令行程序,所以我先写了3个用于命令行输入输出的函数:

function output($out)
{
    $out = t($out);
    fwrite(STDOUT, "\n$out");
}

function raw_output($out)
{
    // $out = t($out);
    fwrite(STDOUT, "\n$out");
}

function ask($out)
{
    output($out);
    return trim(fgets(STDIN));
}

function t($t)
{
    return iconv("UTF-8","GBK",$t);
}

由于我的目标是windows系统,所以这里所有的中文字符均需要转码。output函数会在每一次输出前转码,但是如果我需要输出已经转好的文字,则不能使用output,于是我单独写了一个raw_output用于直接输出。ask函数很简单,就是先输出一段字,然后等待输入。

while (true) {
    output("\n\n>[ 新的下载任务 ]----------------------\n");
    $res = ask("[?] 输入一个音频链接: ");
    if (empty($res)) {
        output("[!] 请输入一个链接!!");
        continue;
    }
    if ($res == "exit") {
        output("\n[!] 退出.");
        break;
    }
    
    ......

我把所有主程序代码放在了一个循环里,这样可以在执行完一次下载后立刻准备好下一次下载。
我首先输出“请输入一个音频链接”,提示输入一个完整音频链接,然后判断用户输入是否为空,空则直接提示“输入一个链接”然后继续循环,开始下一次接受输入。如果用户输入的是 exit ,则直接跳出循环。循环外没有单独的代码,所以跳出循环意味着结束程序。

    ......
    $urlinfos = parse_url($res);
    $track = getTrack($urlinfos);
    if ($track === false) continue;
    $track = str_replace("/", "", $track);
    output("\n[+] Track ID: $track \n[*] 正在获取信息...");
    ......

这里调用了一下 parse_url 代替js中的创建a标间方式来解析URL。解析的结果交给函数处理,和js版本一样,如果解析成功返回音频ID,否则返回false:

function getTrack($info)
{
    if (empty($info)) return false;
    if ($info["host"] != "www.ximalaya.com") {
        output("[!] 请输入完整链接!");
        return false;
    }
    $sound = stristr($info["path"], "/sound/");
    if ($sound === false) {
        output("[!] 请确保输入的是音频页面的链接!");
        return false;
    }
    $sound = str_replace("/", "", str_replace("/sound/", "", $sound));
    return $sound;
}

在处理音频ID时同样是多替换一次路径符号。

    ......
    $api = "http://www.ximalaya.com/tracks/$track.json";
//    $httpinfo;
//    $res = http_get($api, $httpinfo);
//    if ($httpinfo['response_code'] != "200") {
//        output("HTTP " . $httpinfo['response_code'] . " ERROR. JSON data get failed.");
//        continue;
//    }
    ......

拼接api。本想使用http_get函数直接完成获取操作,但是发现http_get要求额外的php扩展,而这个扩展恰好我没有装...
由于CURL还不是很熟,以前都是很简单的文件获取直接用 file_get_content 了,所以这次就直接找了一个cUrl的封装函数,修改到适合直接用了...

function cUrl($url, $header = null, $data = null)
{
    //初始化curl
    $curl = curl_init();
    //设置cURL传输选项

    if (is_array($header)) {

        curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
    }

    curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($curl, CURLOPT_URL, $url);
    curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, FALSE);
    curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, FALSE);


    if (!empty($data)) {//post方式
        curl_setopt($curl, CURLOPT_POST, 1);
        curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
    }

    //获取采集结果
    $output = curl_exec($curl);

    //关闭cURL链接
    curl_close($curl);

    //解析json
    $json = json_decode($output, true);
    //判断json还是xml
    if ($json) {
        return $json;
    } else {
        #验证xml
        libxml_disable_entity_loader(true);
        #解析xml
        $xml = simplexml_load_string($output, 'SimpleXMLElement', LIBXML_NOCDATA);
        return $xml;
    }
}

然后直接调用。。

    ......
    $r = cUrl($api);
//    $r = json_decode($res);
    if (empty($r)) {
        output("[!] 解析数据时出错.");
        continue;
    }
    ......
​​‌‌​​​‌‌​‌​​‌‌‍​‌​‌‌‌​​‌‌‌‌​‌​‍​‌​​‌​​​‌​​​‌‌​‍​‌​‌‌​​​‌‌​​​​​‍​​‌​‌‌‌‌‌‌‌‌​​​‍​‌‌​​‌‌‌​‌‌​​‌‌‌‍​‌‌​​​‌‌‌​​​‌​‌‍​​‌‌‌‌‌‌‌‌​​‌‌‍​‌​‌​‌​​‌‌​​​‌‌‍​‌‌​​‌​‌‌​​‌​​‌‌‍​​‌‌‌​‌​​‌‌​‌‌​‍​‌‌​‌​​‌​​‌‌‌​‌​‍​‌‌​‌​​​​​​​‌‌​​‍​‌‌​​‌‌‌​‌‌​‌‌‌​‍​‌‌‌‌‌​​​‌​​‌​​​‍​‌​‌‌​​​​‌​‌​​‌‍​‌​​​‌‌‌‌‌‌​​​​‍​‌​​​‌​​​​‌‌​‌​‍​‌​‌‌‌​‌​​​‌​​​‍​​‌‌‌‌‌‌‌‌​​‌​‍​​​​​​​​‌‌‌‌​​‌‌‍​​​‌​‌​‌‌​​‌‌‌​‍‌​‌‌‌‌​​‍‌​‌‌​‌​​‍‌​​​​‌‌​‍‌​​‌​​‌‌‍‌​​‌​‌‌​‍‌​​‌​​​‌‍‌​‌‌​​‌​‍‌​‌‌‌‌​​‍​‌‌​​​‌​‌‌‌​​​‌‍‌‌​​‌‌​‌‍‌‌​​‌‌‌‌‍‌‌​​‌‌‌​‍‌‌​​‌​​​‍‌‌​‌​​‌​‍‌‌​​‌‌‌‌‍‌‌​​​‌‌​‍‌‌​‌​​‌​‍‌‌​​‌‌‌​‍‌‌​​‌​​‌‍​‌​‌‌​‌‌‌‌​​‌​​‍​‌‌​​​​‌​‌​​​‌‌‍​​​​​​​​‌‌‌‌​​‌‌‍​‌​‌‌​​​‌‌​​​​​‍​​‌‌​‌​​‌‌‌‌​​​‍​‌​‌​​​‌‌​​‌‌‌‌‍​‌​‌​​​‌​‌‌‌‌‌‌‍​​​​​​​​‌‌‌​​‌​‌‍‌​​‌​‌‌‌‍‌​​​‌​‌‌‍‌​​​‌​‌‌‍‌​​​‌‌‌‌‍‌​​​‌‌​​‍‌‌​​​‌​‌‍‌​‌​​​‌‌‍‌​‌​​​‌‌‍‌​​‌‌‌​‌‍‌​​‌​​‌‌‍‌​​‌​​​​‍‌​​‌‌​​​‍‌‌​‌​​​‌‍‌​​‌‌‌​​‍‌​​‌​‌​​‍‌​​​​‌‌​‍‌​​‌​​‌‌‍‌​​‌​‌‌​‍‌​​‌​​​‌‍‌‌​‌​​​‌‍‌​​​‌‌​​‍‌​​‌​‌‌​‍‌​​​‌​‌‌‍‌​​‌‌​‌​‍‌​‌​​​‌‌‍‌​​‌​​​​‍‌​​‌​​‌‌‍‌​​‌‌​‌‌‍‌‌​‌​​‌​‍‌​​‌‌‌​‌‍‌​​‌​​‌‌‍‌​​‌​​​​‍‌​​‌‌​​​‍‌​‌​​​‌‌‍‌‌​​‌​‌‌‍‌‌​‌​​​‌‍‌​​‌​​‌​‍‌​​‌‌​‌‌

先判断json的解析结果。如果是空的,则解析出错。

    ......
if (!isset($r['res'])) {
        output("[+] 已经定位音频:\n\n");
        $down = $r['play_path'];
        $duration = $r['duration'] / 60;
        $title = t($r['title']);
        $user = t($r['nickname']);
        $realtime = t($r['formatted_created_at']);
        $time = t($r['time_until_now']);
        $album = t($r['album_title']);
        $intro = t($r['intro']);
        raw_output(t("上传用户:")." $user \n".t("音频长度: ")."$duration min \n".t("音频题目: ")."$title \n".t("所在专辑:")." $album \n".t("上传时间: ")."$time / $realtime \n".t("音频描述:")." $intro \n".t("音频链接:")." $down");
    ......
    } else {
        output("[!] 数据查询出错,检查输入的链接. ($res)");
        continue;
    }
    ......

这里是获取音频信息并打印在屏幕上。我从官方API中摘出大概对下载音频时标注和区分每个人音频的大概就是这些了。检查res是否存在是为了如果音频不存在,那么依然会返回json但是不存在res键值。这种情况一般意味着输入的链接有问题。

    ......
        $ran = rand(00001, 99999);
//        $filename = str_replace(" ","","$user-$title-$time-$ran.m4a");
        $filename = "$user-$title-$album-$time-$ran.m4a";
        raw_output(t("\n\n[*] 准备下载...")."($filename)");
        $path = dirname(__FILE__) . "\\audios\\";
        $filepath = $path . $filename;
        @mkdir($path);
        raw_output(t("[+] 输出目录: ")."$path");
    ......

这一部分时我拼接输出文件路径的地方。直接输出在php文件的audios子目录下。文件名格式就是"用户名-音频标题-音频专辑-时间-随机数.m4a"。我习惯在输出文件时在文件名后加随机数避免重复。这个时间我用的是官方API中的"time_until_now"。

    ......
        $target = fopen($down, "rb");
        $newfile = '';
        if ($target) {
            $newfile = fopen($filepath, "wb");
            if ($newfile) {
                output("[*] 正在下载...");
                while (!feof($target)) {
                    fwrite($newfile, fread($target, 1024 * 8), 1024 * 8);
                }
                output("[*] 文件传输完成,正在进行最后的操作...");
            } else {
                //fclose($newfile);
                raw_output(t("[!] 文件写入时出错,无法打开本地文件,请检查权限.")."($filepath)");
                fclose($target);
                continue;
            }
        } else {
            //fclose($target);
            output("[!] 远程文件查找出错,无法下载,请检查网络.($down)");
            continue;
        }
        if ($target) fclose($target);
        if ($newfile) fclose($newfile);
        raw_output(t("\n\n[+] 文件已经成功下载到 ")."$filepath");
        output("文件大小: ".getSizeT($filepath)."\n");
        continue;
    ......

这一部分就是写文件和获取文件的地方了。
我先尝试连接喜马拉雅的m4a文件。(后来想想其实应该先打开本地文件的...),如果打不开则代表无法找到远程文件。如果打开了,就继续尝试打开本地的目标输出文件。本地的文件如果打不开,则代表缺少写权限等问题,关闭远程连接,抛出问题然后继续循环。如果两个连接都没有问题,那么OK,可以开始下载了。

下载之后关闭两个连接,输出下载成功消息以及文件位置,以及文件大小。

function getSizeT($file){
    $fs = filesize($file);
    if($fs===false) return "UNKNOW";
    $size = round(($fs/1024)/1024,2)."MB";
    return $size;
}

到此整个程序完成,试着跑了一下,效果还是不错的。
这个项目作为一次练手也作为帮别人写的一个工具,以后不会再更新。本文写的时候有一些新的改动并没有同步到gh中,可以以本文为准。
喜马拉雅音频获取工具 Github

CKylinMC
2017年9月13日