Download all assets from an obsidian clipper directory.
#!/usr/bin/env python3
"""
Download remote images in clipped Obsidian markdown files to a local attachments
folder and rewrite the links in place.
Already-local links (relative paths, data: URIs) are left untouched, making
the script safe to re-run.
Usage:
embed-clip-images [--attachments DIR] file.md [file2.md ...]
embed-clip-images [--attachments DIR] /path/to/dir/ # recurse all .md files
Defaults:
--attachments ~/Documents/notes/src/to-read/attachments
"""
import argparse
import hashlib
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
DEFAULT_ATTACHMENTS = Path.home() / "Documents/notes/src/to-read/attachments"
DEFAULT_CONTENT_ROOT = Path.home() / "Documents/notes/src"
IMAGE_RE = re.compile(r'(!\[[^\]]*\]\()([^)]+)(\))')
def is_remote(url: str) -> bool:
return url.startswith("http://") or url.startswith("https://")
def local_filename(url: str, attachments: Path) -> Path:
"""Derive a stable local filename from a URL, avoiding collisions."""
parsed = urllib.parse.urlparse(url)
basename = Path(parsed.path).name or "image"
# strip query string from name but keep extension
basename = urllib.parse.unquote(basename).split("?")[0]
if not basename or basename == "/":
basename = "image"
# prefix with a short hash so same-name images from different sites don't clash
short_hash = hashlib.sha1(url.encode()).hexdigest()[:8]
stem = Path(basename).stem[:60] # cap length
suffix = Path(basename).suffix or ".jpg"
return attachments / f"{stem}-{short_hash}{suffix}"
def download(url: str, dest: Path) -> bool:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
dest.write_bytes(resp.read())
return True
except (urllib.error.URLError, OSError) as e:
print(f" WARN: could not download {url}: {e}", file=sys.stderr)
return False
def relative_link(image: Path, content_root: Path) -> str:
return image.relative_to(content_root).as_posix()
def process_file(note: Path, attachments: Path, content_root: Path) -> None:
content = note.read_text(encoding="utf-8")
matches = IMAGE_RE.findall(content)
remote = [(pre, url, post) for pre, url, post in matches if is_remote(url)]
if not remote:
print(f"skip (no remote images): {note.name}")
return
attachments.mkdir(parents=True, exist_ok=True)
changed = False
def replace(m: re.Match) -> str:
nonlocal changed
pre, url, post = m.group(1), m.group(2), m.group(3)
if not is_remote(url):
return m.group(0)
dest = local_filename(url, attachments)
if not dest.exists():
print(f" downloading: {url}")
if not download(url, dest):
return m.group(0) # leave original on failure
link = relative_link(dest, content_root)
changed = True
return f"{pre}{link}{post}"
new_content = IMAGE_RE.sub(replace, content)
if changed:
note.write_text(new_content, encoding="utf-8")
print(f"updated: {note.name}")
def collect_files(paths: list[str]) -> list[Path]:
files = []
for arg in paths:
p = Path(arg)
if p.is_dir():
files.extend(sorted(p.rglob("*.md")))
elif p.is_file():
files.append(p)
else:
print(f"not found: {arg}", file=sys.stderr)
return files
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--attachments", type=Path, default=DEFAULT_ATTACHMENTS,
help="Directory to save downloaded images (default: %(default)s)")
parser.add_argument("--content-root", type=Path, default=DEFAULT_CONTENT_ROOT,
help="Quartz content root; image links are relative to this (default: %(default)s)")
parser.add_argument("paths", nargs="+", metavar="FILE_OR_DIR")
args = parser.parse_args()
for f in collect_files(args.paths):
process_file(f, args.attachments, args.content_root)
if __name__ == "__main__":
main()