侧边栏壁纸
博主头像
问道小站 博主等级

行动起来,活在当下

  • 累计撰写 6 篇文章
  • 累计创建 5 个标签
  • 累计收到 1 条评论

目 录CONTENT

文章目录

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

亦然
2026-03-30 / 0 评论 / 0 点赞 / 1 阅读 / 0 字

下面是一篇整理好的完整、可复现、偏工程化的总结文章,你可以直接用于博客/存档(基本不需要再改)。


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

一、背景与目标

我在 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</code>,再通过 rclone 同步到对象存储。支持最近上传排序、当前页打开原图、预览按钮新标签打开、复制直链、复制 Markdown 链接与分页浏览。</p>
      <div class="badges">
        <span class="badge">Static Site</span>
        <span class="badge">rclone sync</span>
        <span class="badge">Local JSON Index</span>
      </div>
      <div class="stats">
        <div class="stat">
          <div class="label">图片总数</div>
          <div class="value" id="totalCount">-</div>
        </div>
        <div class="stat">
          <div class="label">当前页</div>
          <div class="value" id="currentPageStat">-</div>
        </div>
        <div class="stat">
          <div class="label">最后更新时间</div>
          <div class="value" id="latestUpdate" style="font-size:15px">-</div>
        </div>
      </div>
    </div>
  </section>

  <section class="toolbar">
    <input id="searchInput" class="input" type="text" placeholder="搜索文件名或路径,例如 screen / posts / wallpaper">
    <select id="pageSizeSelect" class="select">
      <option value="12">每页 12 张</option>
      <option value="24" selected>每页 24 张</option>
      <option value="48">每页 48 张</option>
      <option value="96">每页 96 张</option>
    </select>
    <button id="resetBtn" class="btn btn-primary">重置筛选</button>
  </section>

  <section class="panel">
    <div id="grid" class="grid"></div>
    <div id="empty" class="empty" style="display:none;">没有匹配的图片。</div>

    <div class="pagination">
      <button id="prevBtn" class="btn btn-ghost">上一页</button>
      <span id="pageInfo" class="page-info">-</span>
      <button id="nextBtn" class="btn btn-ghost">下一页</button>
    </div>
  </section>

  <div class="footer">
    Built with local JSON index · Arch Linux · systemd · rclone
  </div>
</div>

<div id="toast" class="toast"></div>

<script>
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 < units.length - 1) {
    n /= 1024;
    i++;
  }
  return `${n.toFixed(n >= 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 => ({
    "&":"&amp;",
    "<":"&lt;",
    ">":"&gt;",
    '"':"&quot;",
    "'":"&#39;"
  }[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 `
      <article class="card">
        <a class="thumb-link" href="${item.url}">
          <img class="thumb" src="${item.url}" alt="${escapeHtml(item.name)}" loading="lazy">
        </a>
        <div class="meta">
          <div class="name">${escapeHtml(item.name)}</div>
          <div class="sub">
            <span>${formatSize(item.size)}</span>
            <span>${formatTime(item.mtime)}</span>
          </div>
          <div class="actions">
            <a class="btn btn-ghost" href="${item.url}" target="_blank" rel="noopener noreferrer">预览</a>
            <button class="btn btn-ghost" onclick="copyText('${safeUrlForJs}', '已复制直链')">直链</button>
            <button class="btn btn-ghost" onclick="copyText('${safeMdForJs}', '已复制 Markdown')">Markdown</button>
          </div>
        </div>
      </article>
      `;
    }).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 <= 1;
  document.getElementById("nextBtn").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 = `
      <div class="empty">
        读取 images.json 失败,请确认本地已生成并同步到远端。<br>
        <small>${escapeHtml(String(err.message || err))}</small>
      </div>
    `;
  }
}

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();
</script>
</body>
</html>

功能:

  • 图片网格展示

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

  • 搜索(文件名 / 路径)

  • 分页

  • 点击缩略图 → 当前页打开(支持 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

评论区