KedamaDiff开发日志其二
Overviewer历史地图抓图器模块 开发日志其二:并发抓取多张图片、去重入库与项目私有坐标系构建
多线程抓图
【本实现已被弃用,改为协程抓图,单核并发效率进一步提高。】
一开始,笔者在自己的电脑上运行脚本。虽然能正确运行,但抓取速度只有每秒1张。一次全量抓取约会请求约1500张图片,因此笔者借助于 concurrent.futures
包来实现并发请求。它的线程池模块实现了一个 map()
函数,可以控制线程个数,也可以按请求生成顺序返回响应,即使它们并非按顺序执行完毕。
举例如上,如果任务5一直不响应,map()
循环就会阻塞到天荒地老(但任务9-11,…仍在暗中执行,未被阻塞!)。因此笔者为每个任务编写了单次超时和重试计数(目前给它5次机会)。虽然不知道按顺序返回有什么用,但凭着print的结果漂亮,还是这么写了。//其实 concurrent.futures
自带了超时设置的,这段是我想多了。
生成器的线程安全
在刚刚使用多线程请求图片时,经常会遇到神秘的错误。查阅资料后发现,是两个线程同时访问了一个生成器对象所致。于是使用 threading.lock
为生成器加锁,问题解决。
增量更新:去重与head/抓图/存图逻辑——抓图器核心逻辑。
实现了并发之后,笔者连续爬了好几天的图片。发现大部分图片都与历史图片相同,真正发生更新的图块只有那么点(以我关注的地图为例,20%)。为了节省硬盘空间和被抓图站流量费,笔者实现了增量更新逻辑。//然后某天图站真的欠费了,我很惭愧。
对每个图片URL来说,HEAD
命令可以只返回响应头,而GET
则会连着图片本体一起返回。因此HEAD执行时间更短更省流量。
笔者决定先用HEAD
拿一次响应头,再决定是否请求图片。本站响应头里的字段ETag形如 "5a18280c-bf35"
(包含双引号),猜测横线前后分别是时间戳和(图片)响应长度。同时,笔者也发现了这样的规律:
ETag更新 | —×–> | 图片内容发生变化 | —×–> | 图片长度变化 |
---|---|---|---|---|
ETag更新 | <——- | 图片内容发生变化 | <—— | 图片长度变化 |
因此,设计了以下抓图流程:
对于单张图片(在overviewer中称为tile;在本项目代码中常以’file_name’的变量名出现):
- HEAD URL
- 404(地图此处是虚空,未生成图片):返回 [404]
- 200(此处有图):
- “更新历史”json中没有存这个区域(键):
- 下载图片并用图片名字符串新增一个键,返回;[ADD]
- 有这个键:
- 响应的ETag和更新历史中保存的最新ETag不同:GET URL下载图片,进一步判定 [Upd]
- 两张图片的SHA1不一致:
- 该图更新历史的最新记录为今天:添加记录前删除最新记录 [Rep]lace
- SHA1一致:
- 该图最新更新记录为除今天外的最新更新日期,将该记录下的ETag置为本次HEAD到的最新ETag,但不将其日期改为今天。 [nMod]
- 两张图片的SHA1不一致:
- 响应的ETag和更新历史中保存的最新ETag相同:图片未发生更新,[ign]。
- 响应的ETag和更新历史中保存的最新ETag不同:GET URL下载图片,进一步判定 [Upd]
- “更新历史”json中没有存这个区域(键):
- 若网络超时/中断/其他迷之异常:
- 回到HEAD,共有5次机会,用光了宣告单个图块抓取失败[Fail],就假装今天这图没变[Fail]。
这样一来,只有真正变化的图片才会被保存,其他图片就只费流量了(欧美地区0.04USD/GB,一次全量100MB左右)。
增量更新状态表:
状态 | 物理意义 | 消耗流量 |
---|---|---|
404 | 真实地图上此区域确实没渲染,是虚空 | 微量流量 |
ign | (经ETag判定)此区域相比本地的最新记录未更新 | 微量流量 |
nMod | (经ETag判定)此区域最近被服务端重新渲染但(经sha1判断)内容与本地所存完全一致。 | 图片流量 |
upd | (经ETag判定)此区域更新了 | 图片流量 |
ADD | 此区域从未在本地留下过记录,本次抓取为其留下第一笔记录 | 图片流量 |
replace | 同upd,但更新前的最新记录也是抓取当天 | 图片流量 |
Fail | 此区域抓取多次仍不成功,宣告失败 | 0-多倍图片流量 |
构建自己的地图坐标系
地图的尺度会随时间推移而正常扩大,玩家的游戏行为会使地图异常扩大(疑因下界长期未限制大小,从远处传送回主世界会因1:8的尺度比例而诱使主世界生成遥远地方的区块并扩大地图边界数值)。
前文提到的 path components 无法定位一个(位置和缩放级别都)固定的区域。
下图分别模拟了扩边前后的整张地图,每次扩边(地图缩放级别数增加),最大缩放级别图块的path component都会相应变长,使得原图块URL代表更大的一片区域。
在地图全图缩放尺度无法固定的前提下,不能使用path component来代表固定区域,因此需要为kedamadiff构建专门的坐标系,见下图:
这是最大缩放尺度下的地图图块与KedamaDiff坐标系关系的示意图。每个最小黑框中的色块代表一个地图图块,每个图块占据的面积为2*2,白线用于展示图块的中心点,中心点位于(1,1)的图块会被命名为“0_1_1.jpg”,其右侧肉色图块为”0_3_1.jpg“… 图块面积不为1*1的理由我忘了,但总归是想了好久。
每个图块的中心点对应着整数坐标点,如(1, 1), (-2, -2 (-1级缩放)) 等。在不同尺度下,每个图块的中心点将都是整数坐标。
构建出的坐标系将用于在kedamadiff项目内部描述图块位置,自此入库图块的坐标与Overviewer四叉树图块组织结构解耦。
坐标互转
我们自制的坐标系需经转换才能变为path component形态,转换过程类似于从地图坐标原点(0,0),由最小缩放级别开始,逐级移动到目标图块所在图块的中心坐标上,直到移动到目标图块的中心处。
整体思路:假设我们处于图站的最小缩放级别(一眼望全图),全图共D个缩放级别,设定一动点P拥有坐标($P_X$, $P_Y$)且初始值为(0,0),设定一目标坐标(X, Y)。
⭐D -= 1
若$P_X$ 在目标点左侧($P_X<X$)/右侧($P_X>X$),则P点右移/左移 $2^D$ 格。记录下移动方向。
若$P_Y$ 在目标点下方($P_Y<Y$)/上方($P_Y>Y$),则P点上移/下移 $2^D$ 格。记录下移动方向。
P点在两条轴线上各自的移动方向决定了这层的结果,见下表
P点…… | 下移 | 上移 |
---|---|---|
左移 | /2 | /0 |
右移 | /3 | /1 |
将查表结果append进 path component 中,path component 起始值为 空字符串 。
当P未到达目标坐标时,goto⭐处再次迭代。
探查地图总缩放级别
Overviewer/Mapcrafter 渲染器都会提供js格式的配置文件以便浏览器执行,尽管这些js里写明了全图缩放总级别(zoomLevel/maxZoom),初次编写此段代码时笔者却无从知晓这一切。因此笔者实现该功能的方法是……自行自顶至底逐层探查。
假设Overviewer地图的第一层四叉树构成的四张图块有一不动的中心点C(对应KedamaDiff坐标系中的(0,0)点),则本方法尝试从最小缩放级别开始,逐级访问C点右上方的图块。每成功访问一级图块则地图总缩放级别数+1
这种探查逻辑是建立在一种假设之上,即
”多么小的地图都会渲染C点全部四个方向上的图块“(即地图并非全部图块都在C点一侧),
即右上方的图块不会出现空缺,如在某一层级上发生了缺失就意味着它超出了地图的缩放级别。
由此,当遇到第一个404的图块响应时,代表地图已探查到到最底层,此时返回探查到的层级数。
此方法构建的path component形如 [‘/1’, ‘/1/2’, ‘/1/2/2’, ‘/1/2/2/2’ …].