问题背景

爬取某些英文网页用来翻译时,也下载了网页中包含的图片,原始图片有很多是这样命名的:

https://.../chapter3/1.png

我在本地使用的是扁平的存储结构,为了防止多个 1.png 冲突,重命名为 chapter3-1.png 储存。把英文版翻译了几篇后觉得图片命名方式不太好,应该用 chapter3--1.png 更方便以后重新解析。于是重新解析原始文档并重命名图片,但是这是发现已经翻译的文档中图片名已经难以溯源,不太容易对应到新命名的文件。

关键洞察

原始 URL 是唯一稳定标识,文件名只是临时表示。必须解耦“逻辑资源”与“物理存储”。

解决方案

  1. 建立 image_registry.json{ url: { names: [v1, v2, ...] } }
  2. 下载时追加新命名到历史数组
  3. 构建反向索引:old_name → url → current_name
  4. 提供脚本自动修复 Markdown 中的引用

可复用模式

  • 资源注册表模式(Resource Registry Pattern)
  • 引用可演化设计(Evolvable Reference)
  • 适用于:PDF 转图、多语言文档媒体管理、静态站点资产迁移

实现

用一个字典来保存数据,结构如下:

{
    "https://.../chapter1/transformers_chrono.svg": {
        "names": [
            "chapter1-transformers_chrono.svg",
            "chapter1--transformers_chrono.svg",
            "chapter1_transformers_chrono.svg"
        ],
        "downloaded_at": "2025-11-08T10:21:00Z",
        "content_hash": "sha256:abcd1234..."  // 可选:防重复下载
    }
}

使用原始URL作为键,names 包含了每次变更文件名的记录,约定列表最后一项是最新的名字,每次下载完图片把使用的文件名追加到 names 列表中,如果新文件名和 names 的最后一项相同,则不做任何更改。

例如,文档中正式在使用的图片名是 chapter1-transformers_chrono.svg,但是你已经改成了 chapter1_transformers_chrono.svg,怎么找出来这个新的名字呢?

把字典中的数据建立反向索引,以 names 中的每个值作为键,URL作为值,反转的索引类似:

{
    "chapter1-transformers_chrono.svg": "https://.../chapter1/transformers_chrono.svg",
    "chapter1--transformers_chrono.svg": "https://.../chapter1/transformers_chrono.svg",
    "chapter1_transformers_chrono.svg": "https://.../chapter1/transformers_chrono.svg"
}

用旧名字在这个逆向索引中找到原始URL,再用URL再原始数据中到对应 names 的最后一项,这就获得了最新的文件名。

首先针对字典中使用的数据建立一个类:

from pydantic import BaseModel
from datetime import datetime

class ImageRegistryEntry(BaseModel):
    names: List[str]
    downloaded_at: str
    content_hash: str

    def add_name(self, name: str):
        if not self.names:
            self.names = [name]
            self.downloaded_at = isonow()
            return
        if self.names[-1] == name:
            return
        
        self.names.append(name)
        self.downloaded_at = datetime.now().astimezone().isoformat(timespec='seconds')

    def merge(self, other: 'ImageRegistryEntry'):
        if not other.names:
            return
        self.add_name(other.names[-1])

    @classmethod
    def from_dict(cls, data: Dict):
        return cls(**data)
    

接下来是存储类:

class ImageRegistry:
    def __init__(self, data: Dict[str, Dict] = {}):
        self._data = {
            k: ImageRegistryEntry.from_dict(v)
            for k, v in data.items()
        }
        self._reversed_index: Dict[str, str] = {}

    def __len__(self):
        return len(self._data)

    def get(self, url: str) -> ImageRegistryEntry | None:
        return self._data.get(url, None)

    def set_or_merge(self, url: str, item: ImageRegistryEntry) -> None:
        if url not in self._data:
            self._data[url] = item
            return
        # If exists, merge the last name from item into current entry.
        entry = self._data[url]
        entry.merge(item)
    
    def add_name(self, url: str, name: str) -> ImageRegistryEntry:
        entry: ImageRegistryEntry | None = self._data.get(url, None)
        if not entry:
            entry = ImageRegistryEntry.new(name)
            self._data[url] = entry
            return entry
        
        entry.add_name(name)
        return entry
    
    def reverse_index(self):
        self._reversed_index = {
            name: url
            for url, entry in self._data.items()
            for name in entry.names
        }

    def latest_name(self, url: str) -> str | None:
        entry = self._data.get(url, None)
        if not entry:
            return None
        return entry.names[-1] if entry.names else None
    
    def find_latest_name(self, current_name: str) -> str | None:
        url = self._reversed_index.get(current_name, None)
        if not url:
            return None
        return self.latest_name(url)
    
    def to_dict(self):
        return self._data.copy()

注意,这里的实现中每次写入数据后需要重新调用 reverse_index 来重建逆向索引。

假设你更改了命名规则,需要和已经存在文档同步文件名:

# ![alt text](/path/to/img.jpg "Optional title")
MARKDOWN_IMAGE = re.compile( r'!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)')

registry = load_registry()

def change_image_url(markdown: str, callback: Callable[[str], str]) -> str:
    def replace(match: re.Match):
        alt = match.group(1)
        src = match.group(2)
        new_src = callback(src)

        return f'![{alt}](new_src)'
    
    return MARKDOWN_IMAGE.sub(replace, markdown)

def rename_callback(src: str) -> str:
    p = Path(src)
    latest_name = registry.find_latest_name(p.name)
    if latest_name:
        return str(p.parent / latest_name)
    
    return src

markdown = load_file('file.md')

markdown = change_image_url(markdown, rename_callback)