关于「编程」的一件小事

立泉

作为软件工程师,我知道「编程」可以做什么,但不讳言,工作之外,除去鼓捣过一些软硬结合的玩具,我从没有用它简化过自己日常遇到的问题。直到昨天,虽然只是一件小事但值得记录,我第一次觉得「编程」真的惠及了我的生活,对其心态也开始有一些变化🤔。

几个月前,我在新浪博客上找到了很多喜欢的剧照,这些照片质量很高,甚至保留着相机拍摄时的Metadata元信息,我想把它们都下载收藏起来。其中大部分只要点击网页里的缩略图就能跳转到原图然后下载,但有一些博文里的照片跳转时却会一直加载直到超时也不显示。那时候我以为是新浪的图床已经停止服务,可能原图已经不存在,就只下载了很模糊的缩略图。

昨天,当我再次查看那些可以跳转的图片时,有留意它们在HTML源码中的原图URL,与不能跳转的图片链接进行对比:

# HTML中可以跳转的图A原图URL
http://album.sina.com.cn/pic/4bd5d131nc8d97f90c48d
# HTML中不能跳转的图B原图URL
http://blog.photo.sina.com.cn/showpic.html#url=http://album.sina.com.cn/pic/001xbDZyzy742RZQJY955

熟悉URL结构基本会猜到,链接尾部的这两串字符应该就是图片ID

# 图A的ID
4bd5d131nc8d97f90c48d
# 图B的ID
001xbDZyzy742RZQJY955

图B的原图链接不能跳转肯定和它前面这段URL有关:

http://blog.photo.sina.com.cn/showpic.html

果然,当我用浏览器调试工具把这段前缀去掉,再点击缩略图就跳转到了图B原图。

http://album.sina.com.cn/pic/001xbDZyzy742RZQJY955

又用其它图片试几次,把它们粘贴到专门嗅探媒体资源的Downie里都能成功拿到原图,非常清晰,照片Metadata元信息也在,拍摄的时间和设备参数一目了然。

downloaded pic

到这里,我基本可以猜到为什么有的博客能跳原图而有的不能跳,肯定是新浪博客的开发者在某次迭代中错误的给某些版本的博客原图URL添加了那个前缀,但是又没有搭配正确的解析JS,再加上这个年月博客的用户量实在太少,人微言轻,可能他们到现在都还没有发现这个问题。我甚至怀疑,以目前新浪博客的状态,真的还有人在维护吗…

Now,已经知道之前不能下载的原图其实都还默默存储在新浪的服务器上,而且我当时保存了它们的缩略图,文件名就是上面提到的图片ID,有数百张之多,要如何下载呢?

手动一张张拼接URL显然不现实,我需要写一个「程序」来替我完成读取缩略图的文件名、拼接URL再批量发送到Downie下载队列这三个连续操作,也就是下面这一段代码所做的事。

const val THUMB_DIR = "/Users/apqx/Downloads/thumb";
const val DOWNLOAD_DIR = "/Users/apqx/Downloads/downie";

/**
 * 下载新浪博客原图
 * 只需把想要的照片缩略图从浏览器拖到[THUMB_DIR]中,执行此程序即可
 */
fun main() {
    println(
        """
        Select options, then press enter
        1. Download from thumb
        2. Merge
        """.trimIndent()
    )
    when(readln()) {
        // 下载
        "1" -> {
            downloadByThumbFile(File(THUMB_DIR))
            println("If confirm download is done, press enter to proceed merge, or will close")
            if (readln().isEmpty()) mergeImg()
        }
        // 合并,因为无法监测Downie是否下载完成,所以需手动确认下载完成后再执行合并操作
        "2" -> mergeImg()
    }
}

/**
 * 根据新浪博客缩略图,下载原图。缩略图文件名加上这段URL前缀就是原图链接,发送给Downie下载
 * http://album.sina.com.cn/pic/
 */
fun downloadByThumbFile(thumb: File) {
    if(thumb.isFile) {
        val extension = thumb.extension.lowercase()
        if (!extension.contains("jpg")
            && !extension.contains("jpeg")) {
            println("Jump ${thumb.name}")
            return
        }
        val imgUrl = getImgUrl(thumb.nameWithoutExtension)
        println("Process $imgUrl")
        val downieUrl = getDownieUrl(imgUrl, "$DOWNLOAD_DIR/${thumb.nameWithoutExtension}")
        val shell = "open -a 'Downie 4' '$downieUrl'"
        println(shell)
        // 发送到Downie的下载队列中
        val processBuilder = ProcessBuilder("open", "-a", "Downie 4", downieUrl)
        val process = processBuilder.start()
        process.waitFor()
    } else {
        thumb.listFiles().forEach {
            // 递归
            downloadByThumbFile(it)
        }
    }
}

/**
 * 拼接向Downie中添加下载任务所需的特殊URL
 */
private fun getDownieUrl(imgUrl: String, DOWNLOAD_DIR: String): String {
    val encodedPicUrl = URLEncoder.encode(imgUrl, StandardCharsets.UTF_8)
    return "downie://XUOpenURL?url=$encodedPicUrl&destination=$DOWNLOAD_DIR"

}

/**
 * 拼接照片的原图URL
 */
private fun getImgUrl(fileName: String): String = "http://album.sina.com.cn/pic/" + filleName

/**
 * 整理下载的照片,重命名并移动到[DOWNLOAD_DIR]中
 */
fun mergeImg() {
    File(DOWNLOAD_DIR).listFiles().forEach {
        if (it.isFile) return@forEach
        // 下载的文件就在文件夹里,把文件重命名为父目录名,移动到[DOWNLOAD_DIR]中
        it.listFiles().forEach { img ->
            println("Merge ${it.name}/${img.name}")
            Files.move(img.toPath(), File(it.parent, it.name + ".jpg").toPath(), StandardCopyOption.REPLACE_EXISTING)
            it.delete()
        }

    }
}

可能注意到这段代码分为两步,下载之后还有一个「合并」操作,是因为向Downie中添加下载任务的命令参数有限,只能指定下载URL和下载目录,却不能指定下载后的文件名。而这些照片的默认文件名都是相同的default_s_bmiddle.gif,显然不合适。

# 向Downie中添加下载任务的Terminal命令,Java和Kotlin可以用ProcessBuilder执行
# 注意这里照片的encodedPicUrl是编码后的URL,如果不编码会影响Downie解析
open -a 'Downie 4' 'downie://XUOpenURL?url=${encodedPicUrl}&destination=${DOWNLOAD_DIR}'

所以我把每一张照片都放到了用它ID命名的文件夹中,等所有照片下载完成后按一下Enter键就会自动开始「合并」,最终获得数百张以ID命名的剧照。

idea run

看程序执行时密密麻麻的下载任务,如果真要手动一个个添加,可能要到猴年马月吧。

关于我用它下载了什么,展示一下也无妨,是一些省昆十年前的演出剧照,这个「程序」让我从新浪博客搜刮剧照变得非常简单,只需把文章里想要的照片缩略图拖到电脑里,鼠标点一下「执行」,唰,就下载好了。再回到「编程」的本质,其实也就是自动解决实际问题,几行代码可以节省大量的时间和精力,把它仅仅当作谋生工具而不在日常加以利用真的有点暴殄天物。

opera pics

进化

今天又发现一篇剧照超多的博文,光是把选中的缩略图逐个拖下来就花了很久,而且又看到博主分享的其它剧照文章还有很多,顿时觉得这个下载程序还可以再「进化」一下。

既然是「自动」,就做得彻底一点,只需要提供一个新浪博文的URL链接,它就会解析出正文里所有照片的原图地址并发送到Downie的下载队列中排队下载,然后生成整齐命名的照片文件,堪称「懒人神器」。

程序已经在GitHub上开源,最多的记录是一次性解析下载10篇文章里的980张照片,那个场面,对于一个一直以来动辄花费数小时一张一张保存剧照的我来说,过于「血腥」,可能很难忘记了。

idea run download 980

再进化

又过几天,回过神来,只是下载图片而已,为什么一定要用Downie呢?当时之所以这么做是因为遇到一个问题,同一个URL,用Downie可以下载到原图,而直接用浏览器打开或用Postman请求拿到的却是无意义的占位图。那会儿还有别的事要做就没有深究,既然Downie能用而且还很好用,何乐不为呢。

但是这件事总要找到原因,Charles抓包是一个不错的切入点,然后我就看到了Downie获取真实原图的HTTP请求过程。其实很简单,GET请求然后一个301重定向,再302重定向,就是原图了。

# 第1个请求
GET /pic/4bd5d131nc8d97f90c48d HTTP/1.1
Host	album.sina.com.cn
Refererer	http://album.sina.com.cn/pic/4bd5d131nc8d97f90c48d

# 请求返回,301重定向到http://s14.sinaimg.cn/orignal/4bd5d131nc8d97f90c48d
HTTP/1.1 301 Moved Permanently
Server	nginx/1.0.15
Location	http://s14.sinaimg.cn/orignal/4bd5d131nc8d97f90c48d

# 第2个请求
GET /orignal/4bd5d131nc8d97f90c48d HTTP/1.1
Host	s14.sinaimg.cn

# 请求返回,302重定向到http://image2.sina.com.cn/blog/tmpl/v3/images/default_s_bmiddle.gif
# 即是真实的原图地址
HTTP/1.1 302 Moved Temporarily
Server	web cache
Location	http://image2.sina.com.cn/blog/tmpl/v3/images/default_s_bmiddle.gif

可是当我用Postman执行同样的请求时,虽然也能看到相同的2次重定向,URL也一模一样,但最终拿到的却总是很小的占位图,问题出在哪里呢?

重新梳理几遍这2次请求过程,注意到一个关键不同点,Referer。它在HTTP请求的Header里携带着发起请求站点的URL,通常的防盗链机制就是服务器去检查这个参数标识的请求来源是否是自己的站点,来避免别的站点盗用自己服务器上的资源。我为什么会注意到它呢,因为我自己这个博客托管在阿里云OSS里的图片和视频就使用了同样的防盗链,不久前还配置过域名白名单🙄。

如此一切都清楚了,新浪博客页面上点击缩略图能跳转是因为请求时携带的Referer参数就是新浪的域名Downie能下载是因为它在请求时会把请求URL直接加进Referer里,能通过新浪的防盗链检查,而用浏览器或Postman请求时标识来源站点的Referer参数默认是空的,成功被“防盗链”🙄。

当我在Postman里像Downie那样加上Referer之后,果然抓到了真正的原图文件。

postman referer

然后重写下载程序,不再抛给Downie而是自己实现下载器,把解析到的图片URL抛到线程池中执行,并发量设置为5的话速度还不错,而且摆脱macOS的特有程序Downie意味着这个下载器可以在任何平台的JVM上运行。

开头写道“只是一件小事,但值得记录”,遇到问题后试着找到原因去解决问题,如果在这个过程中能知道一个新领域某个东西的的实现原理,还能做出些解放双手的成果来,真的是「善莫大焉」。

arrow_upward