科技 · 实用指南
把 digiKam 关键词导出为 CSV,保留标签列表边界
标签词表与逐文件关键词表不是同一结果。先保留 ExifTool 的 JSON 数组、输入文件名和元数据命名空间,再转换为 CSV 行。

若要知道每个保存的关键词来自哪个文件,应读取文件元数据,而不是截取标签树。当前 digiKam 标签管理器可导出标签层级,但词表不是逐文件的标签分配表;仅保存在数据库中的更改不在下述文件读取方法范围内。
本文转换器为每个提取的列表项建立一行,并保留命名空间。它不按逗号、竖线或斜线拆分值,不合并不同元数据字段,也不修改照片。这是检查用导出,不是完整备份或自动修复。
先确定要读取哪份证据
扩大范围前,用一个已知文件与 digiKam 显示对照。若预期关键词只在数据库,先停止:文件读取无法导出它们。应按已有备份和同步流程处理数据库与文件差异;本方法不会自动写元数据或改数据库。
明确指定 .xmp 时,SourceFile 指的是该边车文件,不是自动匹配的照片。若同时导出照片及边车,保留两条记录进行比较,不要默默合并或选赢家。保存的选择列表 XML 是另一种证据,不包含这里的关键词值。
用只读 ExifTool 命令提取数组
使用已安装的 ExifTool,先选一份已知输入副本。在 Linux Bash 中于照片文件夹外选全新输出名,替换输入路径后运行下列命令。它仅请求两个指定 XMP 属性,不赋值,也不导入 CSV 或 JSON。
exiftool -j -G1 -struct -XMP-lr:HierarchicalSubject -XMP-digiKam:TagsList -- "/path/to/input.jpg" > metadata.json 2> metadata-errors.txt
转换前检查退出状态、标准错误和 JSON。警告或错误要调查,不能直接压掉;正常文件计数摘要本身不是错误。保留 -j -G1 -struct,不加会拼接列表的 -sep。当前文档说明 -struct 也让单项 XMP 列表保持数组。两个字段可能不同,字段缺失也不能证明照片没有标签。
转换 JSON,逐项保留
将下方完整程序保存为 metadata_to_tag_rows.py。它是一个物理行,用反斜杠转义保留内部换行,请完整保留引号和转义符。它只读 metadata.json,先校验全部记录,再新建输出,拒绝覆盖已有文件,并回读 CSV 逐字段比较。未知字段、重复 JSON 键、非数组或非字符串项会停止转换。
exec('"""Convert a bounded ExifTool JSON export to traceable tag rows; no image writes."""\nimport csv\nimport json\nimport sys\nfrom pathlib import Path\n\nGROUPS = (\'XMP-lr:HierarchicalSubject\', \'XMP-digiKam:TagsList\')\nHEADER = [\'source_record\', \'SourceFile\', \'tag_group\', \'status\', \'item_index\', \'tag_value\']\n\ndef unique_object(pairs):\n result = {}\n for key, value in pairs:\n if key in result:\n raise ValueError(\'Duplicate JSON key; retain the original export\')\n result[key] = value\n return result\n\ndef convert(input_path, output_path):\n data = json.loads(Path(input_path).read_text(encoding=\'utf-8-sig\'), object_pairs_hook=unique_object)\n if not isinstance(data, list):\n raise ValueError(\'Expected the JSON array from the stated ExifTool command\')\n rows = []\n present = 0\n for record_number, record in enumerate(data, 1):\n if not isinstance(record, dict) or not set(record) <= {\'SourceFile\', *GROUPS}:\n raise ValueError(\'Unexpected fields, warning or error; inspect the JSON and stderr\')\n source = record.get(\'SourceFile\')\n if not isinstance(source, str) or not source:\n raise ValueError(\'Expected a nonempty SourceFile string\')\n for group in GROUPS:\n if group not in record:\n rows.append([str(record_number), source, group, \'absent\', \'\', \'\'])\n continue\n values = record[group]\n if not isinstance(values, list) or any(not isinstance(value, str) for value in values):\n raise ValueError(\'Expected string arrays; recheck -j -G1 -struct without -sep\')\n if not values:\n rows.append([str(record_number), source, group, \'empty\', \'\', \'\'])\n for index, value in enumerate(values, 1):\n rows.append([str(record_number), source, group, \'present\', str(index), value])\n present += 1\n with open(output_path, \'x\', encoding=\'utf-8-sig\', newline=\'\') as output:\n writer = csv.writer(output)\n writer.writerow(HEADER)\n writer.writerows(rows)\n with open(output_path, encoding=\'utf-8-sig\', newline=\'\') as output:\n if list(csv.reader(output)) != [HEADER] + rows:\n raise ValueError(\'Readback mismatch; discard this output, retain the JSON\')\n print(f\'{len(data)} source records; {present} present list items; {len(rows)} data rows checked\')\n\nif __name__ == \'__main__\':\n if len(sys.argv) != 3:\n raise SystemExit(\'Usage: python3 metadata_to_tag_rows.py metadata.json tags.csv\')\n convert(sys.argv[1], sys.argv[2])\n')
以新 CSV 名运行 python3 metadata_to_tag_rows.py metadata.json tags.csv。六列为 source_record、SourceFile、tag_group、status、item_index、tag_value。present 行各保留一个未修改项,absent 或 empty 占位保留文件与属性边界。不删除重名文件、重复值或空字符串项。项次序只记录本次 JSON 提取顺序,不代表语义优先级或保证 XMP Bag 有固定顺序。
核对表格并保留适用范围
按 UTF-8、逗号分隔和双引号文本限定符导入,六列均设为文本,关闭公式求值及日期、数字自动转换。对照 JSON 检查含逗号的值、不同层级下同名末级标签、两个字段及预期缺失字段。按解析出的记录计数,不按物理行计数,因为字段可能含换行。
所示程序在 Python 3.12.14 通过六个有效合成 JSON 和八个无效样例,并拒绝六次覆盖已有输出的尝试,覆盖 Unicode、引号、换行、重复值、重名文件和缺失/空列表区别。未运行 ExifTool、digiKam、真实图片元数据、Windows 或 macOS。写入或回读失败时丢弃不完整 CSV,保留 JSON;不保证完整图库、面部区域、照片与边车对账或大型集合处理。
