QQ空间导出照片到NAS
2025 年 07 月 18 日 • 开发
详细介绍了一个自动化工具,用于修复从QQ空间导出的照片中的时间信息丢失问题。

背景:为什么需要这个工具?

最近家里买了一台NAS(网络存储设备),用来备份和管理家庭照片。老婆想把QQ空间里的老照片全部下载下来,存到NAS里,方便以后整理和回忆。然而,从QQ空间导出的照片有一个很大的问题:所有照片的“修改时间”都变成了下载时间,而不是照片实际的拍摄时间。

这样一来,在NAS的相册里,所有照片都挤在“今天”这个时间点,完全失去了时间顺序。对于家庭照片来说,时间信息非常重要——我们希望能按照年份、月份来浏览照片,而不是看到一堆“2025年7月18日”的照片混在一起。

问题分析:为什么照片时间信息丢失了?

  1. QQ空间导出的照片没有EXIF信息
    • EXIF(Exchangeable Image File Format)是存储在照片文件里的元数据,包含拍摄时间、相机型号、GPS位置等信息。
    • 但QQ空间在导出照片时,可能出于隐私或压缩考虑,去掉了这些信息。
  2. 文件名里其实隐藏了时间信息,虽然照片的EXIF没了,但QQ空间导出的文件名通常是这样的格式:
    • IMG_20170815_123456.jpg
    • QQ图片20170815123456.png
    • 其中 20170815 就是拍摄日期(2017年8月15日)。
  3. NAS的相册依赖“文件修改时间”排序
    • 大多数NAS的相册默认会按照文件的“最后修改时间”来排序照片。
    • 如果所有照片的修改时间都是下载时间,那它们就会全部挤在一起,无法按实际拍摄时间分类。

解决方案:用代码恢复照片的正确时间

既然文件名里包含时间信息,我们可以写一个工具,自动解析文件名中的日期,并修改文件的“最后修改时间”,让NAS能正确排序。

代码的核心功能

  1. 分类处理图片和视频
    • 支持 .jpg.png.mp4 等常见格式。
  2. 优先读取EXIF信息(如果有)
    • 如果照片本身有EXIF时间,就直接使用,不修改。
  3. 从文件名解析日期
    • 如果文件名包含 _yyyyMMdd_ 或类似格式,就提取日期,并修改文件时间。
  4. 处理文件名冲突
    • 避免同名文件覆盖,自动添加 (1)(2) 等后缀。
  5. 异常文件单独存放
    • 文件名不符合格式的照片,会被复制到 error 目录,方便手动检查。

代码示例(Java)

xml
复制代码
<dependency> <groupId>com.drewnoakes</groupId> <artifactId>metadata-extractor</artifactId> <version>2.18.0</version> </dependency>
java
复制代码
import com.drew.imaging.ImageMetadataReader; import com.drew.metadata.Metadata; import com.drew.metadata.exif.ExifSubIFDDirectory; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.FileTime; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class Main { private static final String[] IMAGE_EXT = {".jpg", ".jpeg", ".png"}; private static final String[] VIDEO_EXT = {".mp4", ".mov", ".avi"}; private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd"); public static void main(String[] args) throws IOException { String sourceDir = ""; // 源目录 String targetImageDir = ""; String targetVideoDir = ""; String errorDir = ""; // processMedia(sourceDir, targetImageDir, targetVideoDir, errorDir); } public static void processMedia(String srcPath, String imageOut, String videoOut, String errorOut) throws IOException { Files.createDirectories(Paths.get(imageOut)); Files.createDirectories(Paths.get(videoOut)); Files.createDirectories(Paths.get(errorOut)); Files.walk(Paths.get(srcPath)) .filter(Files::isRegularFile) .forEach(path -> { String fileName = path.getFileName().toString(); String lower = fileName.toLowerCase(); try { if (isImage(lower)) { processImage(path, imageOut, errorOut); } else if (isVideo(lower)) { processVideo(path, videoOut, errorOut); } } catch (Exception e) { System.err.println("处理失败:" + path + " => " + e.getMessage()); copyToErrorDir(path, errorOut); // 捕获全局异常,复制到错误目录 } }); } private static boolean isImage(String name) { for (String ext : IMAGE_EXT) { if (name.endsWith(ext)) return true; } return false; } private static boolean isVideo(String name) { for (String ext : VIDEO_EXT) { if (name.endsWith(ext)) return true; } return false; } private static void processImage(Path src, String outputDir, String errorDir) throws IOException { String fileName = src.getFileName().toString(); Date exifDate = extractExifDate(src); // 如果 EXIF 存在,直接复制到目标目录 if (exifDate != null) { Path target = generateUniquePath(Paths.get(outputDir), fileName); Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING); System.out.println("已跳过有 EXIF 的图片: " + fileName); return; } // 无 EXIF 的图片,尝试解析文件名中的日期 String[] parts = fileName.split("_"); if (parts.length < 2) { System.err.println("文件名格式不符合要求: " + fileName); copyToErrorDir(src, errorDir); // 复制到错误目录 return; } try { Date date = DATE_FORMAT.parse(parts[1]); // 解析如 "2017-09-28" long timestamp = date.getTime(); Path target = generateUniquePath(Paths.get(outputDir), fileName); Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING); FileTime time = FileTime.fromMillis(timestamp); Files.setLastModifiedTime(target, time); } catch (ParseException e) { System.err.println("文件名日期解析失败: " + fileName); copyToErrorDir(src, errorDir); // 复制到错误目录 } } private static void processVideo(Path src, String outputDir, String errorDir) throws IOException { String fileName = src.getFileName().toString(); String[] parts = fileName.split("_"); if (parts.length < 2) { System.err.println("文件名格式不符合要求: " + fileName); copyToErrorDir(src, errorDir); // 复制到错误目录 return; } try { Date date = DATE_FORMAT.parse(parts[1]); long timestamp = date.getTime(); Path target = generateUniquePath(Paths.get(outputDir), fileName); Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING); FileTime time = FileTime.fromMillis(timestamp); Files.setLastModifiedTime(target, time); } catch (ParseException e) { System.err.println("文件名日期解析失败: " + fileName); copyToErrorDir(src, errorDir); // 复制到错误目录 } } private static Date extractExifDate(Path imagePath) { try { Metadata metadata = ImageMetadataReader.readMetadata(imagePath.toFile()); ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); if (directory != null) { return directory.getDateOriginal(); } } catch (Exception e) { System.err.println("读取 EXIF 失败: " + imagePath.getFileName() + " => " + e.getMessage()); } return null; } /** * 生成唯一的文件名,避免冲突 */ private static Path generateUniquePath(Path dir, String fileName) { Path target = dir.resolve(fileName); if (!Files.exists(target)) { return target; } // 如果文件已存在,在文件名后添加序号 (1), (2), ... String baseName = fileName.substring(0, fileName.lastIndexOf('.')); String extension = fileName.substring(fileName.lastIndexOf('.')); int counter = 1; while (true) { String newName = baseName + " (" + counter + ")" + extension; Path newTarget = dir.resolve(newName); if (!Files.exists(newTarget)) { return newTarget; } counter++; } } /** * 复制文件到错误目录 */ private static void copyToErrorDir(Path src, String errorDir) { try { Path errorPath = Paths.get(errorDir); Files.createDirectories(errorPath); // 确保目录存在 Path target = generateUniquePath(errorPath, src.getFileName().toString()); Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING); System.out.println("已复制异常文件到错误目录: " + src.getFileName()); } catch (IOException e) { System.err.println("复制到错误目录失败: " + src + " => " + e.getMessage()); } } }

最终效果

运行这个工具后:

  • 照片和视频会按实际拍摄时间排序,在NAS里显示正确的年份和月份。
  • 文件名不符合格式的图片会被放到 error 目录,方便手动处理。
  • 有EXIF的照片不受影响,避免重复处理。

现在,老婆可以在NAS里按照时间轴浏览照片了,再也不用面对一堆“今天”的照片了!🎉

总结

这个工具解决了从QQ空间导出照片后时间信息丢失的问题,让家庭照片在NAS里能正确按时间排序。如果你也遇到类似问题,可以试试这个方案,或者自己写一个类似的脚本。

上一篇
笔记
关于nginx反向代理用DDNS的域名问题
关于使用MP下载馒头种子失败

留言 (0)

昵称(必填)
邮箱(必填)
网址(选填)