基于 rclone + systemd + 本地索引 JSON 的自建图床完整方案

3次阅读
没有评论

一、背景与目标

我在 Claw Cloud(S3 兼容对象存储)上创建了一个 bucket,并绑定自定义域名:

https://images.isrv.cn/

目标是构建一个:

  • 本地可管理(预览 / 重命名 / 分类)

  • 自动同步到对象存储

  • 支持直链访问

  • 带图床首页(可浏览、搜索、复制 Markdown)

  • 完全静态(无后端服务)


二、最终架构

📁 /home/ding/Pictures/Images   ← 本地主库
        ↓
🐍 generate-images-json.py      ← 本地生成索引 JSON
        ↓
🔁 rclone sync (systemd timer)
        ↓
☁️ Claw S3 bucket
        ↓
🌍 https://images.isrv.cn/

核心思想:

本地目录是唯一真源,远端只负责发布和访问


三、目录结构

/home/ding/Pictures/Images/
├── index.html          # 图床首页
├── images.json         # 本地生成的索引
├── screenshots/
├── posts/
├── wallpapers/
└── ...

四、rclone 配置

已配置远端(示例):

rclone ls claw:ujwn4e6y-img

说明:

  • claw:远端名称

  • ujwn4e6y-img:bucket 名


五、生成图片索引(核心)

1. 脚本路径

/home/ding/.local/bin/generate-images-json.py

2. 完整代码

#!/usr/bin/env python3
from __future__ import annotations

import json
import mimetypes
from pathlib import Path
from urllib.parse import quote

ROOT = Path("/home/ding/Pictures/Images")
BASE_URL = "https://images.isrv.cn"
OUTPUT = ROOT / "images.json"

IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".avif"}

def is_image(path: Path) -> bool:
    if path.name in {"index.html", "images.json"}:
        return False
    return path.suffix.lower() in IMAGE_EXTS

def main():
    items = []

    for path in ROOT.rglob("*"):
        if not path.is_file():
            continue

        # 忽略隐藏文件
        if any(part.startswith(".") for part in path.relative_to(ROOT).parts):
            continue

        if not is_image(path):
            continue

        rel = path.relative_to(ROOT).as_posix()
        stat = path.stat()

        items.append({
            "name": path.name,
            "path": rel,
            "url": f"{BASE_URL}/{quote(rel, safe='/')}",
            "mtime": int(stat.st_mtime),
            "size": stat.st_size,
            "type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
        })

    # 按修改时间倒序(最新优先)
    items.sort(key=lambda x: x["mtime"], reverse=True)

    with OUTPUT.open("w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, separators=(",", ":"))

if __name__ == "__main__":
    main()

3. 赋权

chmod +x /home/ding/.local/bin/generate-images-json.py

六、同步脚本

1. 路径

/home/ding/.local/bin/rclone-images-sync.sh

2. 完整代码

#!/usr/bin/env bash
set -euo pipefail

SRC="/home/ding/Pictures/Images"
DST="claw:ujwn4e6y-img"
LOG="/home/ding/.local/state/rclone-images-sync.log"
GEN="/home/ding/.local/bin/generate-images-json.py"

mkdir -p "$(dirname "$LOG")"

# 先生成 JSON 索引
"$GEN"

# 再同步到对象存储
exec /usr/bin/rclone sync "$SRC" "$DST" \
  --fast-list \
  --transfers 8 \
  --checkers 16 \
  --delete-during \
  --track-renames \
  --log-file "$LOG" \
  --log-level INFO

3. 赋权

chmod +x /home/ding/.local/bin/rclone-images-sync.sh

七、systemd 定时任务

1. service

路径:

~/.config/systemd/user/rclone-images-sync.service

内容:

[Unit]
Description=Sync local image directory to Claw object storage
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/home/ding/.local/bin/rclone-images-sync.sh

2. timer

路径:

~/.config/systemd/user/rclone-images-sync.timer

内容:

[Unit]
Description=Run rclone image sync periodically

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true

[Install]
WantedBy=timers.target

3. 启用

systemctl --user daemon-reload
systemctl --user enable --now rclone-images-sync.timer

查看:

systemctl --user list-timers

4. 关键(保持后台运行)

loginctl enable-linger ding

八、图床首页(index.html)

路径:

/home/ding/Pictures/Images/index.html

(此处省略代码,已在上一条提供完整版本)

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>images.isrv.cn</title>
<style>
:root{
  --bg:#0b1020;
  --bg2:#11182d;
  --card:#121a2b;
  --line:#23304a;
  --text:#e8eefc;
  --muted:#9fb0d1;
  --accent:#63b3ff;
  --accent2:#7cf7d4;
  --shadow:0 12px 40px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
  font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Noto Sans CJK SC",sans-serif;
  color:var(--text);
  background:
    radial-gradient(circle at top left, rgba(99,179,255,.18), transparent 30%),
    radial-gradient(circle at top right, rgba(124,247,212,.12), transparent 25%),
    linear-gradient(180deg, #0a0f1d, #0b1020 30%, #0a1020);
  min-height:100vh;
}
.wrap{
  max-width:1200px;
  margin:0 auto;
  padding:32px 18px 48px;
}
.hero{
  display:flex;
  justify-content:space-between;
  gap:20px;
  align-items:flex-start;
  margin-bottom:24px;
  flex-wrap:wrap;
}
.hero-card,.toolbar,.panel{
  background:rgba(18,26,43,.85);
  border:1px solid var(--line);
  box-shadow:var(--shadow);
  backdrop-filter:blur(10px);
}
.hero-card{
  border-radius:22px;
  padding:24px;
  flex:1 1 560px;
}
.hero h1{
  margin:0 0 10px;
  font-size:34px;
  line-height:1.1;
}
.hero p{
  margin:0;
  color:var(--muted);
  line-height:1.7;
}
.hero code{
  background:#0d1425;
  border:1px solid var(--line);
  border-radius:8px;
  padding:2px 8px;
  font-size:.92em;
}
.badges{
  display:flex;
  gap:10px;
  flex-wrap:wrap;
  margin-top:16px;
}
.badge{
  font-size:12px;
  padding:7px 12px;
  border-radius:999px;
  border:1px solid var(--line);
  color:#dbe8ff;
  background:rgba(255,255,255,.03);
}
.toolbar{
  border-radius:18px;
  padding:16px;
  margin-bottom:18px;
  display:grid;
  grid-template-columns:1.2fr 180px 140px;
  gap:12px;
}
.input,.select,.btn{
  width:100%;
  border-radius:12px;
  border:1px solid var(--line);
  background:#0d1425;
  color:var(--text);
  padding:12px 14px;
  font-size:14px;
}
.input:focus,.select:focus{
  outline:none;
  border-color:var(--accent);
}
.btn{
  cursor:pointer;
  transition:.18s ease;
}
.btn:hover{
  transform:translateY(-1px);
}
.btn:disabled{
  opacity:.45;
  cursor:not-allowed;
  transform:none;
}
.btn-primary{
  background:linear-gradient(135deg, var(--accent), #7dc1ff);
  color:#07111f;
  font-weight:700;
  border:none;
}
.btn-ghost{
  background:#10192d;
}
.stats{
  display:flex;
  gap:12px;
  flex-wrap:wrap;
  margin:16px 0 0;
}
.stat{
  min-width:140px;
  border:1px solid var(--line);
  border-radius:14px;
  padding:12px 14px;
  background:rgba(255,255,255,.02);
}
.stat .label{
  font-size:12px;
  color:var(--muted);
}
.stat .value{
  margin-top:6px;
  font-size:20px;
  font-weight:700;
}
.panel{
  border-radius:20px;
  padding:18px;
}
.grid{
  display:grid;
  grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));
  gap:16px;
}
.card{
  border:1px solid var(--line);
  border-radius:18px;
  overflow:hidden;
  background:#0d1425;
}
.thumb-link{
  display:block;
  background:#09101d;
}
.thumb{
  aspect-ratio:4 / 3;
  display:block;
  width:100%;
  object-fit:cover;
  background:#09101d;
}
.meta{
  padding:12px;
}
.name{
  font-size:14px;
  font-weight:600;
  line-height:1.5;
  word-break:break-all;
  min-height:42px;
}
.sub{
  margin-top:8px;
  color:var(--muted);
  font-size:12px;
  display:flex;
  justify-content:space-between;
  gap:8px;
}
.actions{
  display:grid;
  grid-template-columns:1fr 1fr 1fr;
  gap:8px;
  margin-top:12px;
}
.actions .btn{
  padding:10px 8px;
  font-size:13px;
}
.actions a.btn{
  text-decoration:none;
  display:inline-flex;
  align-items:center;
  justify-content:center;
}
.pagination{
  margin-top:22px;
  display:flex;
  justify-content:center;
  align-items:center;
  gap:10px;
  flex-wrap:wrap;
}
.page-info{
  color:var(--muted);
  font-size:14px;
}
.empty{
  text-align:center;
  color:var(--muted);
  padding:48px 20px;
}
.footer{
  text-align:center;
  color:var(--muted);
  font-size:13px;
  margin-top:22px;
}
.toast{
  position:fixed;
  right:20px;
  bottom:20px;
  background:#0f1a2d;
  color:#eaf3ff;
  border:1px solid var(--line);
  border-radius:12px;
  padding:12px 16px;
  box-shadow:var(--shadow);
  opacity:0;
  transform:translateY(10px);
  pointer-events:none;
  transition:.2s ease;
  z-index:9999;
}
.toast.show{
  opacity:1;
  transform:translateY(0);
}
@media (max-width:780px){
  .toolbar{
    grid-template-columns:1fr;
  }
  .hero h1{
    font-size:28px;
  }
}
</style>
</head>
<body>
<div class="wrap">
  <section class="hero">
    <div class="hero-card">

<h1>images.isrv.cn</h1>

<p>本地图床索引页。图片列表由本地扫描生成 <code>images.json,再通过 rclone 同步到对象存储。支持最近上传排序、当前页打开原图、预览按钮新标签打开、复制直链、复制 Markdown 链接与分页浏览。

Static Site rclone sync Local JSON Index
图片总数
-
当前页
-
最后更新时间
-
每页 12 张 每页 24 张 每页 48 张 每页 96 张
没有匹配的图片。
const state = { allItems: [], filteredItems: [], currentPage: 1, pageSize: 24, query: "" }; function formatSize(size) { const units = ["B","KB","MB","GB","TB"]; let i = 0; let n = size; while (n >= 1024 && i = 10 || i === 0 ? 0 : 1)} ${units[i]}`; } function formatTime(ts) { const d = new Date(ts * 1000); return d.toLocaleString("zh-CN", { hour12: false }); } function escapeHtml(str) { return str.replace(/[&"']/g, s => ({ "&":"&", "":">", '"':""", "'":"'" }[s])); } function escapeJsSingleQuoted(str) { return str.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); } function toast(msg) { const el = document.getElementById("toast"); el.textContent = msg; el.classList.add("show"); clearTimeout(toast._timer); toast._timer = setTimeout(() => el.classList.remove("show"), 1400); } async function copyText(text, msg) { try { await navigator.clipboard.writeText(text); toast(msg); } catch (err) { toast("复制失败"); } } function buildMarkdown(item) { return `![${item.name}](${item.url})`; } function applyFilter() { const q = state.query.trim().toLowerCase(); state.filteredItems = !q ? [...state.allItems] : state.allItems.filter(item => item.name.toLowerCase().includes(q) || item.path.toLowerCase().includes(q) ); state.currentPage = 1; render(); } function render() { const total = state.filteredItems.length; const totalPages = Math.max(1, Math.ceil(total / state.pageSize)); if (state.currentPage > totalPages) state.currentPage = totalPages; const start = (state.currentPage - 1) * state.pageSize; const pageItems = state.filteredItems.slice(start, start + state.pageSize); const grid = document.getElementById("grid"); const empty = document.getElementById("empty"); if (!pageItems.length) { grid.innerHTML = ""; empty.style.display = "block"; } else { empty.style.display = "none"; grid.innerHTML = pageItems.map(item => { const safeUrlForJs = escapeJsSingleQuoted(item.url); const safeMdForJs = escapeJsSingleQuoted(buildMarkdown(item)); return `
${escapeHtml(item.name)}
${escapeHtml(item.name)}
${formatSize(item.size)} ${formatTime(item.mtime)}
预览
`; }).join(""); } document.getElementById("pageInfo").textContent = `第 ${state.currentPage} / ${totalPages} 页,共 ${total} 张`; document.getElementById("currentPageStat").textContent = `${state.currentPage}/${totalPages}`; document.getElementById("totalCount").textContent = String(state.allItems.length); document.getElementById("prevBtn").disabled = state.currentPage = totalPages; } async function init() { try { const res = await fetch("images.json?_=" + Date.now(), { cache: "no-store" }); if (!res.ok) throw new Error("无法加载 images.json"); const data = await res.json(); state.allItems = Array.isArray(data) ? data : []; state.filteredItems = [...state.allItems]; if (state.allItems.length) { document.getElementById("latestUpdate").textContent = formatTime(state.allItems[0].mtime); } else { document.getElementById("latestUpdate").textContent = "暂无"; } render(); } catch (err) { document.getElementById("grid").innerHTML = `
读取 images.json 失败,请确认本地已生成并同步到远端。
${escapeHtml(String(err.message || err))}
`; } } document.getElementById("searchInput").addEventListener("input", (e) => { state.query = e.target.value; applyFilter(); }); document.getElementById("pageSizeSelect").addEventListener("change", (e) => { state.pageSize = parseInt(e.target.value, 10); state.currentPage = 1; render(); }); document.getElementById("resetBtn").addEventListener("click", () => { state.query = ""; state.currentPage = 1; state.pageSize = 24; document.getElementById("searchInput").value = ""; document.getElementById("pageSizeSelect").value = "24"; applyFilter(); }); document.getElementById("prevBtn").addEventListener("click", () => { if (state.currentPage > 1) { state.currentPage--; render(); window.scrollTo({ top: 0, behavior: "smooth" }); } }); document.getElementById("nextBtn").addEventListener("click", () => { const totalPages = Math.max(1, Math.ceil(state.filteredItems.length / state.pageSize)); if (state.currentPage < totalPages) { state.currentPage++; render(); window.scrollTo({ top: 0, behavior: "smooth" }); } }); init();

功能:

  • 图片网格展示

  • 按时间排序(最新优先)

  • 搜索(文件名 / 路径)

  • 分页

  • 点击缩略图 → 当前页打开(支持 Alt+← 返回)

  • 预览按钮 → 新标签打开

  • 复制直链

  • 复制 Markdown


九、访问方式

单图访问

https://images.isrv.cn/screen_20260329_195241.png

图床首页

https://images.isrv.cn/

十、验证流程

手动执行同步

/home/ding/.local/bin/rclone-images-sync.sh

查看远端

rclone ls claw:ujwn4e6y-img

浏览器访问

https://images.isrv.cn/

十一、关键设计决策

1. 为什么不用 S3 挂载

  • 非 POSIX 文件系统

  • 预览体验差

  • 性能差

  • 行为不稳定

👉 放弃 mount,采用直传


2. 为什么不用 rsync

  • rsync 适用于文件系统

  • 不理解对象存储语义

👉 使用 rclone 原生支持


3. 为什么本地生成 JSON

对象存储:

  • 不支持目录列表

  • 不支持动态查询

👉 本地生成索引 → 静态加载


4. 为什么用 systemd timer

相比 cron:

  • 支持开机补执行(Persistent)

  • 更好日志管理

  • 可观测性强


5. 为什么用 sync 而不是 copy

rclone sync

保证:

  • 本地删除 → 远端删除

  • 本地修改 → 远端更新

👉 保持完全一致


十二、注意事项

⚠️ 删除风险

sync 会删除远端文件:

本地误删 = 远端也会删除

建议:

  • 初期使用 --dry-run

  • 或增加备份策略


⚠️ 文件命名建议

推荐:

screen_20260329_195241.png
autumn-leaves.png
post-cover.jpg

避免:

  • 空格

  • 中文(可用但不推荐)

  • 特殊符号


⚠️ URL 编码

已在 Python 中处理:

quote(rel, safe='/')

十三、最终效果

你现在拥有:

  • ✔ 本地文件管理体验(完全自由)

  • ✔ 自动同步到对象存储

  • ✔ 自定义域名访问

  • ✔ 可浏览的图床首页

  • ✔ 一键复制 Markdown

  • ✔ 完全静态,无后端依赖


十四、一句话总结

用本地目录作为主库 + rclone 定时同步 + 本地生成 JSON 索引 + 静态页面渲染,就是一个稳定、可控、零后端的图床方案。


如果以后需要继续升级,这一套可以自然扩展到:

  • 自动压缩 / WebP

  • Markdown 自动上传工具(类似 PicGo)

  • 私有签名 URL

  • CDN 加速策略

但就目前而言,这套已经是工程上非常干净且稳定的方案了。

正文完
 0
评论(没有评论)