摘要:把 Google Takeout 导出的 36,000 多个照片/视频迁到一刻相册,听起来只是"上传"一件事,实际做下来是分类、上传、限流对抗、时间还原、去重、删旧、验收七个环节的工程问题。本文按时间顺序复盘每一步的做法与踩过的坑,重点不是某个工具怎么用,而是如何在"没有官方 API、数据量大、错误不可逆"的约束下把事情做对。


一、背景

Google Photos 里攒了十几年的照片,用 Takeout 导出后躺在移动硬盘上:约 36,000 个文件,分在 PVGX、PXGV、PVGVV 等若干目录里(Takeout 的分卷命名),每个媒体文件旁边可能跟着一个 .supplemental-metadata.json,记录着 Google 认定的拍摄时间、地理信息等。

目标:全部搬进一刻相册(百度网盘的相册产品),并且拍摄时间必须正确——照片一旦按错误日期落进时间线,找起来就废了。

网页端上传试过,大批量时经常中断,且无法按自己的规则处理时间。于是改用命令行直连接口(社区项目 pybaiduphoto 已经把协议封装好)。

约束条件很苛刻:

  • 没有官方开放 API,随时可能限流或失效;
  • 数据量大(4GB 只是其中一批坏视频的体积);
  • 云端删除不可逆,一旦删错没有任何回收站兜底。

这三条决定了后面所有设计:一切以"可断点、可回滚、可验证"为前提。


二、第一步:先分类,再动手

拿到 36,000 个文件,第一反应是"传就完了",但盲传会把问题带进云端,之后再修成本翻倍。所以先写了个评估脚本 assess_folder.py,对每个目录做一次体检:

  1. 读同名 .supplemental-metadata.json,取出 photoTakenTime(Google 的权威拍摄时间);
  2. 计算文件 md5,去云端清单里比对;
  3. 比对"云端已记录的拍摄时间"与"Google 记录的时间"是否一致。

按结果把文件分成几类:

分类 含义 处置
没问题 云端已有且时间一致,或本地时间可信 直接上传
可能有问题 云端时间存疑/无 json 人工评估后再定
有问题 确认异常 单独处理或舍弃

评估产出一份 assessment_*.txt,人看了拍板,脚本才动手。这一步的价值在于:把"36,000 个未知"变成"几类已知问题",后面每一步都能针对性处理。

其中 异常/PXGX(160 个)和 异常/PXGV_有问题(1,295 个)经确认是彻底无用的损坏件,用户拍板永久舍弃,不参与上传。

经验:批量处理任何数据前,先花 10% 的时间做分类评估。分类不是拖延,是把不确定性前置。


三、上传链路

3.1 三次握手

文件上传走的是标准的三段式协议:

1
2
precreate  →  superfile2  →  create
 声明文件      传内容        落库

precreate 会带文件的 md5 与大小去询问服务端:

  • return_type=1:服务端没有,需要真正上传;
  • return_type=3:命中去重,秒传完成。

这个返回值后面被我用作免费的查询接口(见第八节)。

3.2 断点续传

上传器 yike_upload.py 的设计:

  • 每个文件成功后追加一行到 yike_upload_log.tsv(时间 / 路径 / 大小 / md5 / 状态);
  • 下次启动先读日志,已成功的(路径 + md5 同时匹配)直接跳过;
  • 日志里区分 ok / dup / skip / fail 四种状态。
1
2
3
# 续传判断:路径和 md5 都对得上才算真的传过
if (path, md5_of(path)) in done:
    continue

用 md5 而不是"文件出现过"做判断很关键——后面我们会反复修改文件字节再重传,如果只认路径,重传就会被错误跳过。

3.3 大文件分片

大于 32MB 的文件一次性 POST 会被服务端掐断。补丁 bigfile_patch.py 改成 4MB 一片的分片上传。注意补丁内部不能再传 timeout 参数,会和已有的请求封装冲突。


四、时间:最容易出错、也最致命的一环

照片迁移的核心不是"传上去",而是"落在正确的时间点上"。这里踩了三个坑。

4.1 Google 给的是 UTC

.supplemental-metadata.json 里的 photoTakenTime.timestamp 是 UTC 秒。直接当成本地时间用,会整体差 8 小时,遇到跨天的照片连日期都错。

1
2
CST = timezone(timedelta(hours=8))
shot = datetime.fromtimestamp(int(ts), CST)   # 必须显式指定时区

4.2 图片认 EXIF,视频认 mvhd

服务端解析拍摄时间的规则并不统一:

  • 图片:读 EXIF 的 DateTimeOriginal;
  • mp4 / mov:读容器里 moov → mvhd 的 creation_time;
  • AVI:只认文件头里的 IDIT 标记(相机写入的墙钟时间),你传什么元数据它都不理。

也就是说,改文件系统的 mtime 完全没用,唯一可控的是改文件内部的字节。这条结论后来救了 600 个视频(见 6.4)。

顺带一个坑:mvhd 的 creation_time 用的是 Mac 1904 纪元,不是 Unix 纪元:

1
2
EPOCH_1904_TO_1970 = 2082844800
creation = unix_ts + EPOCH_1904_TO_1970

4.3 mvhd 解析 bug

批量校验时发现 620 个"云端时间和本地不一致"的视频,一度以为上传出错了。逐条核对后发现:大部分是云端反而正确,是我们的解析代码错了——fix_media_info 里解析 mvhd 的 creation 与 modification 时取错了字段顺序。修掉解析 bug 后,620 个里 556 个自动对齐。

经验:当"不一致"的数量很大时,先怀疑自己的比对代码,而不是数据。


五、限流 errno 50005:与其对抗,不如配合

传到几千个时开始报错:errno 50005 操作过于频繁。

排查过程:

  1. 换 cookie —— 无效(cookie 本身有效,其他请求正常);
  2. 换 clienttype —— 无效,只有 70 是合法值,其余返回 errno=2;
  3. 观察错误发生的位置:只在最后一步 create 失败,precreate 和 superfile2 都正常。

结论:这是账号级的上传限流,没有绕过办法。但实测它是间歇放行的——手动重试 4 次大约能成功 1 次。这意味着"长时间干等"是错的选择,正确策略是短间隔反复试探,别错过放行窗口。

最终退避策略:

1
2
3
4
5
# 连续失败 2 个以上,判定为限流:每个文件只试 1 次(快速失败),避免把窗口越拖越长
tries = 1 if consec_fail >= 2 else 3

# 连续失败 3 个后进入退避:5min → 10min → 20min → 30min(封顶)
nap = min(300 * 2 ** (backoff_cycles - 1), 1800)

配合断点续传,脚本被限流打断后直接重跑即可继续,不需要人工介入。整个 PVGVV(22,239 个文件)就是这么一夜夜跑完的。

经验:对抗型限流没有银弹,能做的是「快速失败 + 指数退避 + 断点续传」三件套,把"被限流"从一个事故降级成一次重试。


六、异常照片处理

6.1 实况照片的"三件套重复"

iPhone 的实况照片(Live Photo)在 Takeout 里不会合成单个文件,而是被拆成两份:一张静态帧(HEIC/JPG)+ 一段 2~3 秒的视频(MP4/MOV)。而用户自己从 App 上传的是 Apple 原始包 .livp。

结果同一秒凑齐三条:.livp + .HEIC + .MP4,md5 各不相同,校验脚本不会报警。

处理方案(用户拍板):保留 .livp,删掉 Google 拆出来的那两份。

配对时踩了两个坑:

  1. Takeout 的静态帧与同名视频拍摄时间系统性差 1 秒(视频早 1 秒),连拍时还会错位一格(HEIC=IMG_0639 配 MP4=IMG_0640)。所以不能用"同一秒"硬碰,必须给 ±5 秒容差;
  2. 光有时间容差会误伤"用户真视频 + 同名封面图"。于是加了一道时长锁:实况片段的真实时长在 0.5~6 秒之间,超过就不配对。
1
2
3
4
# 三重判据,缺一不可
# ① shoot_time 相差 ±5 秒内
# ② 剥离 _\d{10,}_\d+ 后缀后 basename 必须一致
# ③ MP4 真实时长 ≤ 6 秒(排除真视频)

反例很能说明问题:云端有 6 个不同年份的文件都叫 IMG_0574,只看文件名必误配。

这一类前后做了四轮,合计清理 270 组 / 540 条,释放约 21.8 GB,实况本体全部完好。

6.2 无 livp 的"照片 + 实况片段"二选一

还有一批只有静态图 + 视频片段、没有 .livp 的:4,699 组。判断依据是分辨率——静态图 4032×3024(1200 万像素),视频 1920×1440(280 万像素),视频是低一档的衍生物,而且体量是照片的两倍。

用户拍板:删 2~3 秒视频,保留静态照片。执行 4,683 条,零失败,释放 20 GB。

安全判据又加了一层:配对的照片若不在云端,该组视频绝不删。

6.3 日期系统性错误(三批)

有几天照片的日期明显不对。修正思路不是"改个数字",而是找证据链反推真实日期:

批次 原显示 修正为 推断依据
83+88 张 2008-08-16/17/18 2011-06-04/05/06(+1022 天) 相册名写着"周日在上海市",平移必须保持星期不变,2011-06-05 正是周日
344 张 2010-05-06/07 2010-09-22/23(+139 天) 穿着、场景与前后照片序列比对
83 张 2010-08-13 2010-12-24(+133 天) 照片里拍到期末"考试通知"白板(1.05 周三 / 1.10 周一 / 1.12 周三,正是 2011 年 1 月)、背景挂圣诞装饰、冬装、相册名"周五" → 2010-12-24 是周五

修完再重传、再删旧副本,全部零失败。EXIF 修改前整机备份 1,254 个文件,并生成 exif_rollback.tsv(含旧 fsid),随时可回滚。

经验:给照片"改日期"是高风险操作,必须先有独立证据(画面内容、星期、穿着、前后序列),而不是凭感觉调。

6.4 600 个打不开的视频:moov 头损坏

用户反馈"2017 年不少 10 秒视频打不开"。扫描全部 mp4/mov 后发现:600 个文件的 moov box 里,第一个子 box 本该是 mvhd(6d 76 68 64),实际被写成了时间戳数字(如 d5dcb7b1)。播放器和服务端都认不出 mvhd,于是读不到时长、读不到时间 → 无法播放。

视频/音频数据其实完好(H.264 + AAC,trak 完整),只是文件头坏了。

修复办法:重写 mvhd 头 28 字节。

1
2
3
new = struct.pack(">I", size) + b"mvhd" + struct.pack(">I", 0) \
    + struct.pack(">I", creation) + struct.pack(">I", modification) \
    + struct.pack(">I", timescale) + struct.pack(">I", duration)
  • timescale / duration 沿用原文件的值(时长本来就是对的,绝不改动);
  • creation / modification 取 Google json 的 photoTakenTime(保证云端时间不变);
  • 修改前整机字节备份到 _mvhd_fix_backup/。

修完 md5 变了 → 重传 → 服务端重新入库 → 可播放。最后删除云端 600 条旧坏记录。

这里有一个值得记住的调试细节:我第一版扫描脚本取子 box 类型时用了 raw[8:12](那其实是 size 字段),导致 11,351 个文件全部误报为损坏。修正为 raw[12:16] 后才得到真实的 600。改二进制解析后必须先用已知样本验证——正常文件是好的、坏文件是坏的,两头都要能识别。


七、删除:比上传更需要敬畏

上传错了可以重来,删除错了就真没了。所以删除环节设了三道闸:

1. 定位靠 md5,不靠文件名 云端有大量重名文件(前面提到 6 个 IMG_0574)。所有待删目标都先导出 cloud_items.json(含 fsid / md5 / path / shoot_time),再按 md5 精确定位 fsid。

2. 三重安全判据(缺一不可)

1
2
3
① 新副本已经上传成功且时间正确
② 待删的旧 md5 在云端恰好只有 1 条(有多条说明存在歧义,不敢删)
③ 待删 fsid 不在任何"保留列表"里(脚本硬拦截)

3. dry-run → 试点 → 全量

先打印待删清单给人看;再 --limit 3 --commit 试删 3 个,用只读探测接口确认(存在的 fsid 返回 errno=0,已删的返回 50504),并且必须配对照组(拿一个确定存在的 fsid 一起查),这样才能区分"真删掉了"和"接口报错";确认零误伤后才全量执行。

1
2
3
4
res = req.getReqJson(
    "https://photo.baidu.com/youai/file/v1/delete",
    params={"clienttype": "70", "fsid_list": "[{}]".format(fsid)})
# errno == 0 即成功

八、验收:不能靠"看起来对"

踩过两次验收口径的坑,值得单独说。

坑一:统计口径错误 第一次验收报告"照片丢了 9 张",查下来这 9 张在操作之前就不在云端(用户自己删过)。原因是判据顺序把"视频不在 → continue"写在了前面,导致图片视频双缺的组只被计一次。

正确做法:分母必须是 base 快照 ∩ 保留集,也就是"操作前确实存在、且本次应该保留"的集合。多出来的、少的,都相对这个集合来算。

坑二:日志不等于事实 有一批 1,299 个文件是用户在网页端传的,上传日志里没有任何记录,按日志统计会误报成"全部未传"。所以判断"是否传完"必须查云端,不能查自己的日志。

几个好用的验证手段:

  • 秒级查云端时间:对文件跑一次 precreate,返回值里的 data.shoot_time 就是云端当前记录的时间。比全库翻页快几个数量级。
  • 字节级验证:下载云端条目的文件头(Range 请求前 512KB),直接解析 moov 判断能否播放。这是最硬的证据——不依赖"能不能打开"这种主观判断。
  • 分页上限:早期一次全量核对误报 1,836 个"未找到",原因是拉取云端清单时 max_pages 设得太小,根本没翻完。分页工具一定要确认翻到了底。

最终验收结果:

1
2
3
4
新副本在云端:600 / 600
旧坏副本残留:0 条
云端拍摄时间发生变化:0 个
随机抽样 10 条字节级校验:10 / 10 全部 mvhd 正常

九、复盘

从 10 月 22 日到 10 月 29 日,最终结果:

  • 36,084 个文件全部进入云端,时间错位清零;
  • 四轮实况去重释放约 21.8 GB,零误伤;
  • 三批日期修正全部落地,重传与删旧零失败;
  • 600 个损坏视频全部修复可播放。

方法论上最值得留下的几条:

  1. 先分类再动手——把 36,000 个未知变成几类已知问题。
  2. 断点续传是一切的前提——有了它,限流、断网、崩溃都只是"重跑一次"。
  3. 不要用文件名做判据,要用内容指纹(md5 / 尺寸 / 时长)。
  4. 删除前先证明"新的一定在",再动手删旧的;删之前先试 3 个。
  5. 验收要用独立证据:字节、md5、云端快照,而不是"我看了一下好像没问题"。
  6. 改二进制前先备份,改完先用已知样本验证解析代码。
  7. 用户/领域知识不可替代——“2010-12-24 是周五"“这张照片里的白板写着考试通知”,这类信息只有人能提供,脚本再聪明也推不出来。

最后一点感受:这类任务真正的难点从来不是"调用某个接口”,而是在不可逆、无官方文档、数据量大的约束下,把每一步都做成可验证、可回滚的。工具会失效,接口会变,这套方法论不会。