关于「编程」的一件小事

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

几个月前,我在新浪博客上找到很多喜欢的剧照,这些照片质量很高,甚至保留着相机拍摄时的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里携带着请求发起者的URL,通常防盗链机制就是服务器去检查这个参数标识的请求来源是否是自己的站点,来避免其它网站盗用自身资源。为什么会注意到它呢,因为我自己这个博客托管在阿里云OSS里的图片就在使用同样的防盗链,不久前还配置过域名白名单🙄。

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

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

postman referer

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

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

arrow_upward