KedamaDiff开发日志其二

Author Avatar
xmoiduts 8月 05, 2021
  • 用其他设备扫码打开本文

Overviewer历史地图抓图器模块 开发日志其二:并发抓取多张图片、去重入库与项目私有坐标系构建

多线程抓图

【本实现已被弃用,改为协程抓图,单核并发效率进一步提高。】

一开始,笔者在自己的电脑上运行脚本。虽然能正确运行,但抓取速度只有每秒1张。一次全量抓取约会请求约1500张图片,因此笔者借助于 concurrent.futures 包来实现并发请求。它的线程池模块实现了一个 map() 函数,可以控制线程个数,也可以按请求生成顺序返回响应,即使它们并非按顺序执行完毕。

concurrent_futures_map.png

举例如上,如果任务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]
        • 响应的ETag和更新历史中保存的最新ETag相同:图片未发生更新,[ign]。
  • 若网络超时/中断/其他迷之异常:
    • 回到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代表更大的一片区域。

无异常扩边.jpg

有异常扩边.jpg

在地图全图缩放尺度无法固定的前提下,不能使用path component来代表固定区域,因此需要为kedamadiff构建专门的坐标系,见下图:

自制坐标系.png

这是最大缩放尺度下的地图图块与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’ …].