关于「编程」的一件小事

作为软件工程师,我知道编程可以做什么,但不讳言,工作之外除去鼓捣过一些软硬结合的玩具我从没有用它解决过日常遇到的问题。直到昨天,虽然是一件小事但值得记录,第一次觉得程序像魔法一样“神奇”,对其心态开始有“欣慰”变化。

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

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

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

对 URL 结构的本能猜测,链接尾部的两串字符应该是图片 ID:

4bd5d131nc8d97f90c48d
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 命令
# 注意照片的 encodedPicUrl 必须是编码后 URL,否则会影响 Downie 解析
open -a 'Downie 4' 'downie://XUOpenURL?url=${encodedPicUrl}&destination=${DOWNLOAD_DIR}'

所以我把每一张照片都放到用它 ID 命名的文件夹中,等全部下载完成后按一下 Enter 键执行“合并”,最终获得数百张以 ID 命名的剧照。

idea run

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

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

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
Referer	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 也一模一样,但最终拿到的却总是很小的占位图,问题出在哪里呢?

梳理几遍请求过程注意到一个关键不同点,Referer。它在 HTTP 请求的 Header 里携带请求发起者的 Hostname,通常“防盗链”就是服务器去检查这个参数标识的请求来源是否是自己的站点来避免其它网站盗用自身资源。为什么注意到它呢,因为我自己这个博客托管在阿里云 OSS 里的图片就在使用同样的防盗链机制,不久前配置过域名白名单。

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

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

postman referer

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

开头写道“只是一件小事但值得记录”,遇到问题试着探查原因,如果在剥茧抽丝中知道某个领域的实现原理并做出解放双手的成果来,是十分“善莫大焉”的。

arrow_upward