Watch at Ease看見世界,讀懂生活。

科技 · 實用指南

Google Takeout 相片顯示今天日期?修改前先稽核 EXIF 與 JSON 配對

用唯讀試行報告分開下載時間戳、內嵌標籤與側車日期;檔名配對不等於獲准改寫中繼資料。

原創概念插圖:相片匯出中的檔案時間、內嵌標籤與 JSON 日期證據流入唯讀稽核表。
原創概念插圖:在考慮任何中繼資料變更前,先在唯讀證據表中分開檔案系統時鐘、內嵌標籤與 JSON 側車日期。 Watch at Ease

Google Takeout 可能在同一張相片旁留下多層日期:下載時指派的檔案系統修改時間、內嵌 EXIF 或影片標籤,以及含 photoTakenTime 等欄位的 JSON 側車。它們不能互換。Google 官方說明也指出,作業系統可能為下載檔案指派新的時間戳,而相片或影片的中繼資料仍可在檔案詳細資訊中找到。

危險的捷徑是把每個顯示今天日期的檔案都視為損壞,並立即把 JSON 值寫入媒體。檔名截短、編輯版、副本,以及舊式與 supplemental-metadata 命名差異,都可能讓多個側車看似合理。錯誤的一對一配對,可能悄悄把數千張相片移到錯誤日期。

本指南只為可信的解壓副本建立有界證據表,所有日期比較與中繼資料更改都保留給人工複核。缺少中繼資料記錄時標示為 not-inspected,而不是斷言檔案沒有拍攝日期。

保全匯出檔並唯讀擷取內嵌標籤

保留原始壓縮檔,並先完成所有分卷的解壓縮。請在名為 Takeout 的可信獨立副本上工作,不使用符號連結,並把報告檔放在該目錄樹之外。本試行最多支援 10,000 個來源檔案;先在小型且具代表性的副本上執行。它會為每個來源檔計算兩次雜湊,可能產生大量磁碟讀取;候選檔名掃描也採保守規則,並不驗證所有可能的 Takeout 配置。

透過 ExifTool 官方發行管道安裝 ExifTool,並準備 Python 3.9 或以上版本。在上層目錄中,以 Linux Bash 執行下列唯讀 ExifTool 範本。使用全新的 embedded.json,檢查 stderr 與結束狀態;讀取失敗就停止。這是標籤匯出,不是 -json= 匯入;沒有賦值、tagsFromFile 或覆寫選項。此命令已對照 ExifTool 官方參考核驗,但本輪沒有實際執行。

( set -o noclobber; exiftool -r -json -G1 -s -DateTimeOriginal -CreateDate -OffsetTimeOriginal -OffsetTimeDigitized -Error -Warning "Takeout" > "embedded.json" )

產生有界 CSV 報告

目前資料夾內應有 Takeout 與 embedded.json。把下一條命令複製成完整一行,並保留直引號與反斜線。它會在來源目錄樹之外以獨占建立方式產生 takeout-audit.csv,因此既有報告會被拒絕覆寫。我們透過 Linux Bash 與合成中繼資料記錄實際執行了 Python 轉換;真實 ExifTool/媒體擷取及 Windows、macOS 執行均未測試。

報告清點 JPG/JPEG、HEIC、PNG、GIF、MP4、MOV、TIF/TIFF 與 WEBP 副檔名,包含解析後的原始內嵌記錄、選定的 JSON 日期證據,以及明確標示為 UTC 的檔案系統修改時間。它不會解碼媒體,也不能證明檔案有效。建立輸出前,來源檔大小、奈秒 mtime、雜湊與檔案集合必須保持不變;之後還會回讀並逐項比較 CSV 標頭與所有欄位。

python3 -c 'exec("import csv, json, sys, hashlib\nfrom pathlib import Path\nfrom datetime import datetime, timezone\ndef pairs(items):\n result = {}\n for k, v in items:\n if k in result: raise ValueError('"'"'Duplicate JSON key'"'"')\n result[k] = v\n return result\ndef read_json(p):\n return json.loads(p.read_bytes().decode('"'"'utf-8-sig'"'"'), object_pairs_hook=pairs, parse_constant=lambda x: (_ for _ in ()).throw(ValueError('"'"'Nonstandard number'"'"')))\ndef utc(v):\n if not isinstance(v, dict) or '"'"'timestamp'"'"' not in v: return '"'"''"'"'\n t = v['"'"'timestamp'"'"']\n if type(t) is int: n = t\n elif isinstance(t, str) and t.lstrip('"'"'-'"'"').isdigit(): n = int(t)\n else: raise ValueError('"'"'Invalid epoch'"'"')\n return datetime.fromtimestamp(n, timezone.utc).isoformat()\nroot, metadata, output = map(Path, sys.argv[1:])\nroot = root.resolve()\nif not root.is_dir(): raise ValueError('"'"'Need extracted root directory'"'"')\nif output.resolve().is_relative_to(root): raise ValueError('"'"'Output must be outside source tree'"'"')\nfiles = sorted(p for p in root.rglob('"'"'*'"'"') if p.is_file())\nif len(files) > 10000: raise ValueError('"'"'Bounded pilot only: at most 10000 source files'"'"')\nif any(p.is_symlink() for p in root.rglob('"'"'*'"'"')): raise ValueError('"'"'Symlinks unsupported'"'"')\ndef digest(p):\n h = hashlib.sha256()\n with p.open('"'"'rb'"'"') as f:\n for chunk in iter(lambda: f.read(1048576), b'"'"''"'"'): h.update(chunk)\n return h.hexdigest()\ndef snapshot():\n return {str(p.relative_to(root)): (p.stat().st_size, p.stat().st_mtime_ns, digest(p)) for p in files}\nbefore = snapshot()\nmedia = [p for p in files if p.suffix.lower() in {'"'"'.jpg'"'"','"'"'.jpeg'"'"','"'"'.heic'"'"','"'"'.png'"'"','"'"'.gif'"'"','"'"'.mp4'"'"','"'"'.mov'"'"','"'"'.tif'"'"','"'"'.tiff'"'"','"'"'.webp'"'"'}]\nsidecars = [p for p in files if p.suffix.lower() == '"'"'.json'"'"']\ndata = read_json(metadata)\nif not isinstance(data, list): raise ValueError('"'"'Expected ExifTool JSON array'"'"')\nembedded = {}\nfor item in data:\n if not isinstance(item, dict) or not isinstance(item.get('"'"'SourceFile'"'"'), str): raise ValueError('"'"'Missing SourceFile'"'"')\n path = Path(item['"'"'SourceFile'"'"']).resolve()\n if not path.is_relative_to(root): raise ValueError('"'"'Metadata path outside root'"'"')\n if path in embedded: raise ValueError('"'"'Duplicate metadata row'"'"')\n embedded[path] = item\ncandidates = {}\nfor p in media:\n exact = [q for q in sidecars if q.parent == p.parent and q.name in (p.name+'"'"'.json'"'"', p.name+'"'"'.supplemental-metadata.json'"'"')]\n weak = [q for q in sidecars if q.parent == p.parent and q not in exact and len(q.name[:-5]) >= 5 and (p.name.startswith(q.name[:-5]) or q.name.startswith(p.name+'"'"'.s'"'"'))]\n candidates[p] = (exact, weak)\nused = {}\nfor p, (exact, weak) in candidates.items():\n for q in exact + weak: used[q] = used.get(q, 0) + 1\nrows = []\nfor p in media:\n exact, weak = candidates[p]; all_candidates = exact + weak\n state = '"'"'missing'"'"' if not all_candidates else '"'"'ambiguous-truncated'"'"' if weak else '"'"'ambiguous-duplicate'"'"' if len(exact) != 1 or used[exact[0]] != 1 else '"'"'unique'"'"'\n if '"'"'-edited'"'"' in p.stem.lower(): state = '"'"'edited-variant'"'"'\n evidence = []\n for q in all_candidates:\n try:\n value = read_json(q)\n if not isinstance(value, dict): raise ValueError('"'"'Sidecar is not object'"'"')\n evidence.append({'"'"'path'"'"':str(q.relative_to(root)), '"'"'photoTakenTimeUTC'"'"':utc(value.get('"'"'photoTakenTime'"'"')), '"'"'creationTimeUTC'"'"':utc(value.get('"'"'creationTime'"'"')), '"'"'title'"'"':value.get('"'"'title'"'"'), '"'"'error'"'"':'"'"''"'"'})\n except (ValueError, KeyError, OverflowError, OSError, UnicodeError) as e:\n evidence.append({'"'"'path'"'"':str(q.relative_to(root)), '"'"'error'"'"':type(e).__name__})\n state = '"'"'invalid-json'"'"'\n info = embedded.get(p)\n if info is None: embedded_status = '"'"'not-inspected'"'"'\n elif any(k.split('"'"':'"'"')[-1] in ('"'"'Error'"'"','"'"'Warning'"'"') for k in info): embedded_status = '"'"'read-warning-or-error'"'"'\n else: embedded_status = '"'"'record-present-not-capture-proof'"'"'\n rows.append([str(p.relative_to(root)),p.suffix.lower(),str(p.stat().st_size),datetime.fromtimestamp(p.stat().st_mtime,timezone.utc).isoformat(),embedded_status,json.dumps(info or {},ensure_ascii=False),json.dumps(evidence,ensure_ascii=False),state,'"'"'manual-comparison-required'"'"','"'"'No metadata write authorized'"'"'])\nif sorted(p for p in root.rglob('"'"'*'"'"') if p.is_file()) != files or snapshot() != before: raise ValueError('"'"'Source file set/byte/hash/mtime changed; no report created'"'"')\nheader = ['"'"'relative_media_path'"'"','"'"'media_type'"'"','"'"'size_bytes'"'"','"'"'filesystem_mtime_utc'"'"','"'"'embedded_read_status'"'"','"'"'embedded_tags_raw_json'"'"','"'"'candidate_date_evidence_json'"'"','"'"'sidecar_match_status'"'"','"'"'date_source_agreement'"'"','"'"'manual_review_reason'"'"']\nwith output.open('"'"'x'"'"',encoding='"'"'utf-8-sig'"'"',newline='"'"''"'"') as f:\n w=csv.writer(f);w.writerow(header);w.writerows(rows)\nwith output.open(encoding='"'"'utf-8-sig'"'"',newline='"'"''"'"') as f:\n if list(csv.reader(f)) != [header]+rows: raise ValueError('"'"'CSV readback mismatch'"'"')\nprint(len(media),'"'"'media rows; source bytes/hashes/mtimes unchanged; no metadata written'"'"')\n")' "Takeout" "embedded.json" "takeout-audit.csv"

解讀配對與日期,但不要猜測

十個欄位依序為 relative_media_path、media_type、size_bytes、filesystem_mtime_utc、embedded_read_status、embedded_tags_raw_json、candidate_date_evidence_json、sidecar_match_status、date_source_agreement 與 manual_review_reason。相對路徑可區分不同資料夾中的同名檔案。原始內嵌 JSON 保留可用的選定標籤與偏移;缺少記錄標為 not-inspected,出現讀取警告或錯誤也不等於已驗證拍攝日期。

在同一目錄內,精確候選是完整媒體檔名再加 .json 或 .supplemental-metadata.json。弱候選包括:至少五個字元的 JSON 基名是媒體名的前綴,或縮短的 .s… 後綴形式。所有候選及其解析錯誤都會保留。多個精確候選標為 ambiguous-duplicate;只要出現弱候選就標為 ambiguous-truncated;檔名含 -edited 時為 edited-variant;無效 JSON/日期資料為 invalid-json。其他命名變體仍可能漏掉,所以 missing 只表示依這些規則未找到候選,不證明側車不存在。

unique 只表示在這組有限規則下有一個未被其他媒體列共用的精確候選,不能證明內容屬於該相片。JSON 的 photoTakenTime 與 creationTime epoch 會分別以 UTC 顯示,但不會選任何欄位作為正確拍攝日期。每列仍為 manual-comparison-required:缺乏可靠時區的 EXIF 本地時間不能作為精確時刻比較。提出變更前,應保留原始文字,並複核標題、影像內容、相簿脈絡與偏移。

驗證涵蓋範圍,並把修復留在獨立階段

報告中的媒體數量必須等於以所列副檔名找到的檔案數;由於欄位可能包含換行,CSV 記錄數不能用實體文字行數代替。請從每個相關分支開啟樣本:靜態圖片、影片、編輯項目、重名檔案與非 ASCII 檔名,並把實際媒體與側車一起檢查。未檢查或讀取出錯的內嵌記錄,以及弱配對、缺失、編輯版或無效候選,都需要人工複核。

以 UTF-8、逗號分隔、直雙引號方式匯入 CSV。把每一欄設為文字,並關閉公式求值與自動修剪,以免檔名和原始證據改變。保留 JSON/CSV 主檔,只在工作副本中註記。若解析、雜湊、寫入或回讀失敗,捨棄不完整輸出,調查來源並改用新的報告名稱。此 CSV 不是 ExifTool 匯入檔,不能更新中繼資料。

十六個真實 Linux Bash 合成案例涵蓋舊/新精確名稱、缺失與競爭候選、共用截短、編輯名稱、格式錯誤/重複鍵 JSON、無效 epoch、含 Unicode/逗號/換行的路徑、不同目錄同名、未檢查影片、讀取錯誤與空匯出;另外十六次覆寫嘗試被拒絕,來源位元組、雜湊與 mtime 都維持不變。未測試真實封存檔、ExifTool、GUI 或遷移。後續修復計畫必須列出精確子集、來源欄位、各媒體格式的目標標籤、備份與回復,並在副本上驗證寫入。僅在某個應用程式中排序正確,並不足以證明遷移正確。

原創概念插圖:一條可追溯的相片—JSON 配對,與重複、截短、缺失和無效側車分支形成對照。
原創概念插圖:唯一候選仍只是證據;重複、截短、缺失與無效分支必須停在人工複核,不能寫入中繼資料。 Watch at Ease