关于「编程」的一件小事
立泉作为软件工程师,我知道编程可以做什么,但不讳言,工作之外除去鼓捣过一些软硬结合的玩具我从未用它解决过日常遇到的问题。直到昨天,虽然是一件小事但值得记录,第一次觉得程序像魔法一样“神奇”,心态开始有“欣慰”变化。
数月前我在新浪博客里找到很多高质量剧照,一些甚至保留着相机拍摄时的 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 元信息也在,拍摄的时间和设备参数一目了然。
![]()
至此,我基本猜到为什么有的博文能跳原图而有的不能,一定是开发者在某次迭代中错误的给某些原图 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 命名的剧照。
![]()
看程序执行时密密麻麻的下载任务,如果真要手动一个个添加,可能要到猴年马月吧。
关于我用它下载了什么,展示无妨,是一些省昆十年前的演出剧照。这个程序让我从新浪博客搜刮剧照变得非常简单,只需将文章中的缩略图拖到 Desktop 再鼠标点一下“执行”即可。编程的本质是自动化解决实际问题,几行代码可以节省大量时间精力,把它仅当作谋生工具不在日常利用确是暴殄天物。
![]()
进化
今天又发现一篇剧照超多的博文,光是把选中的缩略图拖下来已经感觉不轻松,而且看到博主分享的剧照文章还有很多,顿时觉得下载程序应该“进化”一下。
“自动”得彻底一点,只需提供新浪博文的 URL 链接,由它解析出正文所有照片的原图地址发送给 Downie 排队下载,生成整齐命名的照片文件,可称“懒人神器”。
最多的记录是一次性解析下载 10 篇文章里的 980 张照片,那个场面对于一个一直以来动辄花费数小时逐张保存剧照的我来说十分“血腥”,很难忘记。
![]()
再进化
过几天回过神来,下载图片为什么一定要用 Downie ?当时这么做是因为遇到一个问题,同一个 URL 用 Downie 可以下载到原图,直接用浏览器打开或用 Postman 请求拿到的却是无意义占位图。那时有别的事情要做没有深究。
但这件事总要找到原因,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 之后果然能拿到真正的原图文件。

重写下载程序,不再抛给 Downie 而是自己实现下载器,把解析到的图片 URL 抛到线程池中执行,并发量设置为 8 速度很不错,而且摆脱 macOS 特有程序 Downie 意味着这个下载器可以在任何平台的 JVM 上运行。
开头写道“只是一件小事但值得记录”,遇到问题试着探查原因,如果剥茧抽丝知道某个领域的实现原理并做出解放双手的成果来,是十分“善莫大焉”的。